From 735b0435ed9fd4d46ae58e9d1d37272ecb755eb4 Mon Sep 17 00:00:00 2001 From: Guillaume Mulocher Date: Thu, 14 Mar 2024 16:22:25 +0100 Subject: [PATCH] chore(anta): Use Ruff and remove black and flake8 (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Matthieu Tâche Co-authored-by: Carl Baillargeon --- .devcontainer/devcontainer.json | 2 - .github/generate_release.py | 22 +- .github/workflows/code-testing.yml | 4 +- .gitignore | 1 - .pre-commit-config.yaml | 36 +- .vscode/settings.json | 18 +- anta/__init__.py | 1 + anta/aioeapi.py | 16 +- anta/catalog.py | 251 ++++++---- anta/cli/__init__.py | 12 +- anta/cli/check/__init__.py | 7 +- anta/cli/check/commands.py | 14 +- anta/cli/console.py | 6 +- anta/cli/debug/__init__.py | 7 +- anta/cli/debug/commands.py | 44 +- anta/cli/debug/utils.py | 41 +- anta/cli/exec/__init__.py | 17 +- anta/cli/exec/commands.py | 57 ++- anta/cli/exec/utils.py | 69 ++- anta/cli/get/__init__.py | 7 +- anta/cli/get/commands.py | 34 +- anta/cli/get/utils.py | 122 +++-- anta/cli/nrfu/__init__.py | 27 +- anta/cli/nrfu/commands.py | 21 +- anta/cli/nrfu/utils.py | 45 +- anta/cli/utils.py | 105 ++-- anta/custom_types.py | 29 +- anta/decorators.py | 34 +- anta/device.py | 201 ++++---- anta/inventory/__init__.py | 155 +++--- anta/inventory/exceptions.py | 2 +- anta/inventory/models.py | 56 +-- anta/logger.py | 62 ++- anta/models.py | 228 +++++---- anta/reporter/__init__.py | 81 +-- anta/result_manager/__init__.py | 91 ++-- anta/result_manager/models.py | 49 +- anta/runner.py | 69 ++- anta/tests/__init__.py | 1 + anta/tests/aaa.py | 184 +++---- anta/tests/bfd.py | 97 ++-- anta/tests/configuration.py | 45 +- anta/tests/connectivity.py | 75 +-- anta/tests/field_notices.py | 76 +-- anta/tests/greent.py | 49 +- anta/tests/hardware.py | 131 ++--- anta/tests/interfaces.py | 319 ++++++------ anta/tests/lanz.py | 23 +- anta/tests/logging.py | 154 +++--- anta/tests/mlag.py | 126 ++--- anta/tests/multicast.py | 51 +- anta/tests/profiles.py | 50 +- anta/tests/ptp.py | 222 +++++---- anta/tests/routing/__init__.py | 1 + anta/tests/routing/bgp.py | 468 +++++++++--------- anta/tests/routing/generic.py | 88 ++-- anta/tests/routing/ospf.py | 97 ++-- anta/tests/security.py | 306 ++++++------ anta/tests/services.py | 90 ++-- anta/tests/snmp.py | 123 ++--- anta/tests/software.py | 75 +-- anta/tests/stp.py | 100 ++-- anta/tests/system.py | 132 ++--- anta/tests/vlan.py | 26 +- anta/tests/vxlan.py | 121 ++--- anta/tools/__init__.py | 4 + anta/tools/get_dict_superset.py | 11 +- anta/tools/get_item.py | 11 +- anta/tools/get_value.py | 27 +- anta/tools/utils.py | 14 +- docs/README.md | 3 +- docs/scripts/generate_svg.py | 20 +- examples/tests.yaml | 12 +- pyproject.toml | 143 ++++-- tests/__init__.py | 1 + tests/conftest.py | 19 +- tests/data/__init__.py | 1 + tests/data/json_data.py | 15 +- tests/lib/__init__.py | 1 + tests/lib/anta.py | 14 +- tests/lib/fixture.py | 150 +++--- tests/lib/utils.py | 22 +- tests/units/__init__.py | 1 + tests/units/anta_tests/__init__.py | 1 + tests/units/anta_tests/routing/__init__.py | 1 + tests/units/anta_tests/routing/test_bgp.py | 131 +++-- .../units/anta_tests/routing/test_generic.py | 47 +- tests/units/anta_tests/routing/test_ospf.py | 97 ++-- tests/units/anta_tests/test_aaa.py | 105 ++-- tests/units/anta_tests/test_bfd.py | 7 +- tests/units/anta_tests/test_configuration.py | 3 +- tests/units/anta_tests/test_connectivity.py | 101 ++-- tests/units/anta_tests/test_field_notices.py | 37 +- tests/units/anta_tests/test_greent.py | 22 +- tests/units/anta_tests/test_hardware.py | 103 ++-- tests/units/anta_tests/test_interfaces.py | 159 +++--- tests/units/anta_tests/test_lanz.py | 5 +- tests/units/anta_tests/test_logging.py | 17 +- tests/units/anta_tests/test_mlag.py | 43 +- tests/units/anta_tests/test_multicast.py | 19 +- tests/units/anta_tests/test_profiles.py | 9 +- tests/units/anta_tests/test_ptp.py | 97 +++- tests/units/anta_tests/test_security.py | 15 +- tests/units/anta_tests/test_services.py | 5 +- tests/units/anta_tests/test_snmp.py | 5 +- tests/units/anta_tests/test_software.py | 11 +- tests/units/anta_tests/test_stp.py | 55 +- tests/units/anta_tests/test_system.py | 38 +- tests/units/anta_tests/test_vlan.py | 5 +- tests/units/anta_tests/test_vxlan.py | 43 +- tests/units/cli/__init__.py | 1 + tests/units/cli/check/__init__.py | 1 + tests/units/cli/check/test__init__.py | 18 +- tests/units/cli/check/test_commands.py | 11 +- tests/units/cli/debug/__init__.py | 1 + tests/units/cli/debug/test__init__.py | 18 +- tests/units/cli/debug/test_commands.py | 19 +- tests/units/cli/exec/__init__.py | 1 + tests/units/cli/exec/test__init__.py | 18 +- tests/units/cli/exec/test_commands.py | 32 +- tests/units/cli/exec/test_utils.py | 98 ++-- tests/units/cli/get/__init__.py | 1 + tests/units/cli/get/test__init__.py | 18 +- tests/units/cli/get/test_commands.py | 100 +++- tests/units/cli/get/test_utils.py | 53 +- tests/units/cli/nrfu/__init__.py | 1 + tests/units/cli/nrfu/test__init__.py | 39 +- tests/units/cli/nrfu/test_commands.py | 43 +- tests/units/cli/test__init__.py | 29 +- tests/units/inventory/__init__.py | 1 + tests/units/inventory/test_inventory.py | 19 +- tests/units/inventory/test_models.py | 76 ++- tests/units/reporter/__init__.py | 1 + tests/units/reporter/test__init__.py | 57 +-- tests/units/result_manager/__init__.py | 1 + tests/units/result_manager/test__init__.py | 111 +++-- tests/units/result_manager/test_models.py | 7 +- tests/units/test_catalog.py | 101 ++-- tests/units/test_device.py | 134 +++-- tests/units/test_logger.py | 68 +-- tests/units/test_models.py | 243 ++++++--- tests/units/test_runner.py | 30 +- tests/units/tools/__init__.py | 1 + tests/units/tools/test_get_dict_superset.py | 132 ++++- tests/units/tools/test_get_item.py | 14 +- tests/units/tools/test_get_value.py | 117 ++++- tests/units/tools/test_utils.py | 30 +- 147 files changed, 4728 insertions(+), 3840 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 80633caf2..0c13d2c02 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,8 +15,6 @@ "vscode": { "settings": {}, "extensions": [ - "ms-python.black-formatter", - "ms-python.isort", "formulahendry.github-actions", "matangover.mypy", "ms-python.mypy-type-checker", diff --git a/.github/generate_release.py b/.github/generate_release.py index 56b650017..97f139b7f 100644 --- a/.github/generate_release.py +++ b/.github/generate_release.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -""" -generate_release.py +"""generate_release.py. This script is used to generate the release.yml file as per https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes @@ -20,18 +19,15 @@ "fix": "Bug Fixes", "cut": "Cut", "doc": "Documentation", - # "CI": "CI", "bump": "Bump", - # "test": "Test", "revert": "Revert", "refactor": "Refactoring", } class SafeDumper(yaml.SafeDumper): - """ - Make yamllint happy - https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586 + """Make yamllint happy + https://github.com/yaml/pyyaml/issues/234#issuecomment-765894586. """ # pylint: disable=R0901,W0613,W1113 @@ -60,7 +56,7 @@ def increase_indent(self, flow=False, *args, **kwargs): { "title": "Breaking Changes", "labels": breaking_labels, - } + }, ) # Add new features @@ -71,7 +67,7 @@ def increase_indent(self, flow=False, *args, **kwargs): { "title": "New features and enhancements", "labels": feat_labels, - } + }, ) # Add fixes @@ -82,7 +78,7 @@ def increase_indent(self, flow=False, *args, **kwargs): { "title": "Fixed issues", "labels": fixes_labels, - } + }, ) # Add Documentation @@ -93,7 +89,7 @@ def increase_indent(self, flow=False, *args, **kwargs): { "title": "Documentation", "labels": doc_labels, - } + }, ) # Add the catch all @@ -101,7 +97,7 @@ def increase_indent(self, flow=False, *args, **kwargs): { "title": "Other Changes", "labels": ["*"], - } + }, ) with open(r"release.yml", "w", encoding="utf-8") as release_file: yaml.dump( @@ -109,7 +105,7 @@ def increase_indent(self, flow=False, *args, **kwargs): "changelog": { "exclude": {"labels": exclude_list}, "categories": categories_list, - } + }, }, release_file, Dumper=SafeDumper, diff --git a/.github/workflows/code-testing.yml b/.github/workflows/code-testing.yml index 4d4c0a68d..555efe97c 100644 --- a/.github/workflows/code-testing.yml +++ b/.github/workflows/code-testing.yml @@ -82,7 +82,7 @@ jobs: config_file: .yamllint.yml file_or_dir: . lint-python: - name: Run isort, black, flake8 and pylint + name: Check the code style runs-on: ubuntu-20.04 needs: file-changes if: needs.file-changes.outputs.code == 'true' @@ -97,7 +97,7 @@ jobs: - name: "Run tox linting environment" run: tox -e lint type-python: - name: Run mypy + name: Check typing runs-on: ubuntu-20.04 needs: file-changes if: needs.file-changes.outputs.code == 'true' diff --git a/.gitignore b/.gitignore index d7a069993..a62de025c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ share/python-wheels/ *.egg-info/ .installed.cfg *.egg -.flake8 # PyInstaller # Usually these files are written by a python script from a template diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56309c65e..31784327d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,33 +40,21 @@ repos: - --comment-style - '' - - repo: https://github.com/pycqa/isort - rev: 5.13.2 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.2 hooks: - - id: isort - name: Check for changes when running isort on all python files - - - repo: https://github.com/psf/black - rev: 24.1.1 - hooks: - - id: black - name: Check for changes when running Black on all python files - - - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - name: Check for PEP8 error on Python files - args: - - --config=/dev/null - - --max-line-length=165 + - id: ruff + name: Run Ruff linter + args: [ --fix ] + - id: ruff-format + name: Run Ruff formatter - repo: local # as per https://pylint.pycqa.org/en/latest/user_guide/installation/pre-commit-integration.html hooks: - id: pylint entry: pylint language: python - name: Check for Linting error on Python files + name: Check code style with pylint description: This hook runs pylint. types: [python] args: @@ -74,17 +62,11 @@ repos: - -sn # Don't display the score - --rcfile=pylintrc # Link to config file - # Prepare to turn on ruff - # - repo: https://github.com/astral-sh/ruff-pre-commit - # # Ruff version. - # rev: v0.0.280 - # hooks: - # - id: ruff - - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.9.0 hooks: - id: mypy + name: Check typing with mypy args: - --config-file=pyproject.toml additional_dependencies: diff --git a/.vscode/settings.json b/.vscode/settings.json index ff14cc179..8428c00f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,8 @@ { - "black-formatter.importStrategy": "fromEnvironment", + "ruff.enable": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, "pylint.importStrategy": "fromEnvironment", - "pylint.args": [ - "--rcfile=pylintrc" - ], - "flake8.importStrategy": "fromEnvironment", - "flake8.args": [ - "--config=/dev/null", - "--max-line-length=165" - ], "mypy-type-checker.importStrategy": "fromEnvironment", "mypy-type-checker.args": [ "--config-file=pyproject.toml" @@ -17,14 +11,10 @@ "refactor": "Warning" }, "pylint.args": [ - "--load-plugins pylint_pydantic", + "--load-plugins", "pylint_pydantic", "--rcfile=pylintrc" ], "python.testing.pytestArgs": [ "tests" ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, - "isort.importStrategy": "fromEnvironment", - "isort.check": true, } \ No newline at end of file diff --git a/anta/__init__.py b/anta/__init__.py index 397328899..58dcd2cf7 100644 --- a/anta/__init__.py +++ b/anta/__init__.py @@ -2,6 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Arista Network Test Automation (ANTA) Framework.""" + import importlib.metadata import os diff --git a/anta/aioeapi.py b/anta/aioeapi.py index 3ede8a474..f99ea1851 100644 --- a/anta/aioeapi.py +++ b/anta/aioeapi.py @@ -1,7 +1,7 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13""" +"""Patch for aioeapi waiting for https://github.com/jeremyschulman/aio-eapi/pull/13.""" from __future__ import annotations from typing import Any, AnyStr @@ -12,8 +12,7 @@ class EapiCommandError(RuntimeError): - """ - Exception class for EAPI command errors + """Exception class for EAPI command errors. Attributes ---------- @@ -25,8 +24,8 @@ class EapiCommandError(RuntimeError): """ # pylint: disable=too-many-arguments - def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]): - """Initializer for the EapiCommandError exception""" + def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str | dict[str, Any]], not_exec: list[dict[str, Any]]) -> None: + """Initializer for the EapiCommandError exception.""" self.failed = failed self.errmsg = errmsg self.errors = errors @@ -35,7 +34,7 @@ def __init__(self, failed: str, errors: list[str], errmsg: str, passed: list[str super().__init__() def __str__(self) -> str: - """returns the error message associated with the exception""" + """Returns the error message associated with the exception.""" return self.errmsg @@ -43,8 +42,7 @@ def __str__(self) -> str: async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ignore - """ - Execute the JSON-RPC dictionary object. + """Execute the JSON-RPC dictionary object. Parameters ---------- @@ -101,7 +99,7 @@ async def jsonrpc_exec(self, jsonrpc: dict) -> list[dict | AnyStr]: # type: ign failed=commands[err_at]["cmd"], errors=cmd_data[err_at]["errors"], errmsg=err_msg, - not_exec=commands[err_at + 1 :], # noqa: E203 + not_exec=commands[err_at + 1 :], ) diff --git a/anta/catalog.py b/anta/catalog.py index 621872073..a9fc76dcc 100644 --- a/anta/catalog.py +++ b/anta/catalog.py @@ -1,19 +1,25 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Catalog related functions -""" +"""Catalog related functions.""" + from __future__ import annotations import importlib import logging from inspect import isclass from pathlib import Path -from types import ModuleType -from typing import Any, Dict, List, Optional, Tuple, Type, Union - -from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, ValidationInfo, field_validator, model_validator +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union + +from pydantic import ( + BaseModel, + ConfigDict, + RootModel, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) from pydantic.types import ImportString from pydantic_core import PydanticCustomError from yaml import YAMLError, safe_load @@ -21,6 +27,9 @@ from anta.logger import anta_log_exception from anta.models import AntaTest +if TYPE_CHECKING: + from types import ModuleType + logger = logging.getLogger(__name__) # { : [ { : }, ... ] } @@ -31,8 +40,7 @@ class AntaTestDefinition(BaseModel): - """ - Define a test with its associated inputs. + """Define a test with its associated inputs. test: An AntaTest concrete subclass inputs: The associated AntaTest.Input subclass instance @@ -40,13 +48,13 @@ class AntaTestDefinition(BaseModel): model_config = ConfigDict(frozen=True) - test: Type[AntaTest] + test: type[AntaTest] inputs: AntaTest.Input - def __init__(self, **data: Any) -> None: - """ - Inject test in the context to allow to instantiate Input in the BeforeValidator - https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization + def __init__(self, **data: type[AntaTest] | AntaTest.Input | dict[str, Any] | None) -> None: + """Inject test in the context to allow to instantiate Input in the BeforeValidator. + + https://docs.pydantic.dev/2.0/usage/validators/#using-validation-context-with-basemodel-initialization. """ self.__pydantic_validator__.validate_python( data, @@ -57,19 +65,26 @@ def __init__(self, **data: Any) -> None: @field_validator("inputs", mode="before") @classmethod - def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: ValidationInfo) -> AntaTest.Input: - """ + def instantiate_inputs( + cls: type[AntaTestDefinition], + data: AntaTest.Input | dict[str, Any] | None, + info: ValidationInfo, + ) -> AntaTest.Input: + """Ensure the test inputs can be instantiated and thus are valid. + If the test has no inputs, allow the user to omit providing the `inputs` field. If the test has inputs, allow the user to provide a valid dictionary of the input fields. This model validator will instantiate an Input class from the `test` class field. """ if info.context is None: - raise ValueError("Could not validate inputs as no test class could be identified") + msg = "Could not validate inputs as no test class could be identified" + raise ValueError(msg) # Pydantic guarantees at this stage that test_class is a subclass of AntaTest because of the ordering # of fields in the class definition - so no need to check for this test_class = info.context["test"] if not (isclass(test_class) and issubclass(test_class, AntaTest)): - raise ValueError(f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest") + msg = f"Could not validate inputs as no test class {test_class} is not a subclass of AntaTest" + raise ValueError(msg) if isinstance(data, AntaTest.Input): return data @@ -80,114 +95,140 @@ def instantiate_inputs(cls, data: AntaTest.Input | dict[str, Any] | None, info: return test_class.Input(**data) except ValidationError as e: inputs_msg = str(e).replace("\n", "\n\t") - raise PydanticCustomError("wrong_test_inputs", f"{test_class.name} test inputs are not valid: {inputs_msg}\n", {"errors": e.errors()}) from e - raise ValueError(f"Coud not instantiate inputs as type {type(data).__name__} is not valid") + err_type = "wrong_test_inputs" + raise PydanticCustomError( + err_type, + f"{test_class.name} test inputs are not valid: {inputs_msg}\n", + {"errors": e.errors()}, + ) from e + msg = f"Coud not instantiate inputs as type {type(data).__name__} is not valid" + raise ValueError(msg) @model_validator(mode="after") - def check_inputs(self) -> "AntaTestDefinition": - """ + def check_inputs(self) -> AntaTestDefinition: + """Check the `inputs` field typing. + The `inputs` class attribute needs to be an instance of the AntaTest.Input subclass defined in the class `test`. """ if not isinstance(self.inputs, self.test.Input): - raise ValueError(f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}") + msg = f"Test input has type {self.inputs.__class__.__qualname__} but expected type {self.test.Input.__qualname__}" + raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError return self class AntaCatalogFile(RootModel[Dict[ImportString[Any], List[AntaTestDefinition]]]): # pylint: disable=too-few-public-methods - """ - This model represents an ANTA Test Catalog File. + """Represents an ANTA Test Catalog File. - A valid test catalog file must have the following structure: + Example: + ------- + A valid test catalog file must have the following structure: + ``` : - : + ``` + """ - root: Dict[ImportString[Any], List[AntaTestDefinition]] + root: dict[ImportString[Any], list[AntaTestDefinition]] + + @staticmethod + def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: + """Allow the user to provide a data structure with nested Python modules. + + Example: + ------- + ``` + anta.tests.routing: + generic: + - + bgp: + - + ``` + `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. + """ + modules: dict[ModuleType, list[Any]] = {} + for module_name, tests in data.items(): + if package and not module_name.startswith("."): + # PLW2901 - we redefine the loop variable on purpose here. + module_name = f".{module_name}" # noqa: PLW2901 + try: + module: ModuleType = importlib.import_module(name=module_name, package=package) + except Exception as e: # pylint: disable=broad-exception-caught + # A test module is potentially user-defined code. + # We need to catch everything if we want to have meaningful logs + module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}" + message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues." + anta_log_exception(e, message, logger) + raise ValueError(message) from e + if isinstance(tests, dict): + # This is an inner Python module + modules.update(AntaCatalogFile.flatten_modules(data=tests, package=module.__name__)) + else: + if not isinstance(tests, list): + msg = f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog." + raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError + # This is a list of AntaTestDefinition + modules[module] = tests + return modules + + # ANN401 - Any ok for this validator as we are validating the received data + # and cannot know in advance what it is. @model_validator(mode="before") @classmethod - def check_tests(cls, data: Any) -> Any: - """ - Allow the user to provide a Python data structure that only has string values. + def check_tests(cls: type[AntaCatalogFile], data: Any) -> Any: # noqa: ANN401 + """Allow the user to provide a Python data structure that only has string values. + This validator will try to flatten and import Python modules, check if the tests classes are actually defined in their respective Python module and instantiate Input instances with provided value to validate test inputs. """ - - def flatten_modules(data: dict[str, Any], package: str | None = None) -> dict[ModuleType, list[Any]]: - """ - Allow the user to provide a data structure with nested Python modules. - - Example: - ``` - anta.tests.routing: - generic: - - - bgp: - - - ``` - `anta.tests.routing.generic` and `anta.tests.routing.bgp` are importable Python modules. - """ - modules: dict[ModuleType, list[Any]] = {} - for module_name, tests in data.items(): - if package and not module_name.startswith("."): - module_name = f".{module_name}" - try: - module: ModuleType = importlib.import_module(name=module_name, package=package) - except Exception as e: # pylint: disable=broad-exception-caught - # A test module is potentially user-defined code. - # We need to catch everything if we want to have meaningful logs - module_str = f"{module_name[1:] if module_name.startswith('.') else module_name}{f' from package {package}' if package else ''}" - message = f"Module named {module_str} cannot be imported. Verify that the module exists and there is no Python syntax issues." - anta_log_exception(e, message, logger) - raise ValueError(message) from e - if isinstance(tests, dict): - # This is an inner Python module - modules.update(flatten_modules(data=tests, package=module.__name__)) - else: - if not isinstance(tests, list): - raise ValueError(f"Syntax error when parsing: {tests}\nIt must be a list of ANTA tests. Check the test catalog.") - # This is a list of AntaTestDefinition - modules[module] = tests - return modules - if isinstance(data, dict): - typed_data: dict[ModuleType, list[Any]] = flatten_modules(data) + typed_data: dict[ModuleType, list[Any]] = AntaCatalogFile.flatten_modules(data) for module, tests in typed_data.items(): test_definitions: list[AntaTestDefinition] = [] for test_definition in tests: if not isinstance(test_definition, dict): - raise ValueError(f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog.") + msg = f"Syntax error when parsing: {test_definition}\nIt must be a dictionary. Check the test catalog." + raise ValueError(msg) # noqa: TRY004 pydantic catches ValueError or AssertionError, no TypeError if len(test_definition) != 1: - raise ValueError( - f"Syntax error when parsing: {test_definition}\nIt must be a dictionary with a single entry. Check the indentation in the test catalog." + msg = ( + f"Syntax error when parsing: {test_definition}\n" + "It must be a dictionary with a single entry. Check the indentation in the test catalog." ) + raise ValueError(msg) for test_name, test_inputs in test_definition.copy().items(): test: type[AntaTest] | None = getattr(module, test_name, None) if test is None: - raise ValueError( - f"{test_name} is not defined in Python module {module.__name__}{f' (from {module.__file__})' if module.__file__ is not None else ''}" + msg = ( + f"{test_name} is not defined in Python module {module.__name__}" + f"{f' (from {module.__file__})' if module.__file__ is not None else ''}" ) + raise ValueError(msg) test_definitions.append(AntaTestDefinition(test=test, inputs=test_inputs)) typed_data[module] = test_definitions return typed_data class AntaCatalog: - """ - Class representing an ANTA Catalog. + """Class representing an ANTA Catalog. It can be instantiated using its contructor or one of the static methods: `parse()`, `from_list()` or `from_dict()` """ - def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str | Path | None = None) -> None: - """ - Constructor of AntaCatalog. + def __init__( + self, + tests: list[AntaTestDefinition] | None = None, + filename: str | Path | None = None, + ) -> None: + """Instantiate an AntaCatalog instance. Args: + ---- tests: A list of AntaTestDefinition instances. filename: The path from which the catalog is loaded. + """ self._tests: list[AntaTestDefinition] = [] if tests is not None: @@ -201,34 +242,38 @@ def __init__(self, tests: list[AntaTestDefinition] | None = None, filename: str @property def filename(self) -> Path | None: - """Path of the file used to create this AntaCatalog instance""" + """Path of the file used to create this AntaCatalog instance.""" return self._filename @property def tests(self) -> list[AntaTestDefinition]: - """List of AntaTestDefinition in this catalog""" + """List of AntaTestDefinition in this catalog.""" return self._tests @tests.setter def tests(self, value: list[AntaTestDefinition]) -> None: if not isinstance(value, list): - raise ValueError("The catalog must contain a list of tests") + msg = "The catalog must contain a list of tests" + raise TypeError(msg) for t in value: if not isinstance(t, AntaTestDefinition): - raise ValueError("A test in the catalog must be an AntaTestDefinition instance") + msg = "A test in the catalog must be an AntaTestDefinition instance" + raise TypeError(msg) self._tests = value @staticmethod def parse(filename: str | Path) -> AntaCatalog: - """ - Create an AntaCatalog instance from a test catalog file. + """Create an AntaCatalog instance from a test catalog file. Args: + ---- filename: Path to test catalog YAML file + """ try: - with open(file=filename, mode="r", encoding="UTF-8") as file: - data = safe_load(file) + file: Path = filename if isinstance(filename, Path) else Path(filename) + with file.open(encoding="UTF-8") as f: + data = safe_load(f) except (TypeError, YAMLError, OSError) as e: message = f"Unable to parse ANTA Test Catalog file '{filename}'" anta_log_exception(e, message, logger) @@ -238,15 +283,17 @@ def parse(filename: str | Path) -> AntaCatalog: @staticmethod def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> AntaCatalog: - """ - Create an AntaCatalog instance from a dictionary data structure. + """Create an AntaCatalog instance from a dictionary data structure. + See RawCatalogInput type alias for details. It is the data structure returned by `yaml.load()` function of a valid YAML Test Catalog file. Args: + ---- data: Python dictionary used to instantiate the AntaCatalog instance filename: value to be set as AntaCatalog instance attribute + """ tests: list[AntaTestDefinition] = [] if data is None: @@ -254,12 +301,17 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta return AntaCatalog(filename=filename) if not isinstance(data, dict): - raise ValueError(f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}") + msg = f"Wrong input type for catalog data{f' (from {filename})' if filename is not None else ''}, must be a dict, got {type(data).__name__}" + raise TypeError(msg) try: catalog_data = AntaCatalogFile(**data) # type: ignore[arg-type] except ValidationError as e: - anta_log_exception(e, f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", logger) + anta_log_exception( + e, + f"Test catalog is invalid!{f' (from {filename})' if filename is not None else ''}", + logger, + ) raise for t in catalog_data.root.values(): tests.extend(t) @@ -267,12 +319,14 @@ def from_dict(data: RawCatalogInput, filename: str | Path | None = None) -> Anta @staticmethod def from_list(data: ListAntaTestTuples) -> AntaCatalog: - """ - Create an AntaCatalog instance from a list data structure. + """Create an AntaCatalog instance from a list data structure. + See ListAntaTestTuples type alias for details. Args: + ---- data: Python list used to instantiate the AntaCatalog instance + """ tests: list[AntaTestDefinition] = [] try: @@ -282,15 +336,18 @@ def from_list(data: ListAntaTestTuples) -> AntaCatalog: raise return AntaCatalog(tests) - def get_tests_by_tags(self, tags: list[str], strict: bool = False) -> list[AntaTestDefinition]: - """ - Return all the tests that have matching tags in their input filters. + def get_tests_by_tags(self, tags: list[str], *, strict: bool = False) -> list[AntaTestDefinition]: + """Return all the tests that have matching tags in their input filters. + If strict=True, returns only tests that match all the tags provided as input. If strict=False, return all the tests that match at least one tag provided as input. """ result: list[AntaTestDefinition] = [] for test in self.tests: if test.inputs.filters and (f := test.inputs.filters.tags): - if (strict and all(t in tags for t in f)) or (not strict and any(t in tags for t in f)): + if strict: + if all(t in tags for t in f): + result.append(test) + elif any(t in tags for t in f): result.append(test) return result diff --git a/anta/cli/__init__.py b/anta/cli/__init__.py index 3eecad061..44e5c6711 100644 --- a/anta/cli/__init__.py +++ b/anta/cli/__init__.py @@ -1,10 +1,8 @@ -#!/usr/bin/env python # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -ANTA CLI -""" +"""ANTA CLI.""" + from __future__ import annotations import logging @@ -16,7 +14,7 @@ from anta import GITHUB_SUGGESTION, __version__ from anta.cli.check import check as check_command from anta.cli.debug import debug as debug_command -from anta.cli.exec import exec as exec_command +from anta.cli.exec import _exec as exec_command from anta.cli.get import get as get_command from anta.cli.nrfu import nrfu as nrfu_command from anta.cli.utils import AliasedGroup, ExitCode @@ -47,7 +45,7 @@ ), ) def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> None: - """Arista Network Test Automation (ANTA) CLI""" + """Arista Network Test Automation (ANTA) CLI.""" ctx.ensure_object(dict) setup_logging(log_level, log_file) @@ -60,7 +58,7 @@ def anta(ctx: click.Context, log_level: LogLevel, log_file: pathlib.Path) -> Non def cli() -> None: - """Entrypoint for pyproject.toml""" + """Entrypoint for pyproject.toml.""" try: anta(obj={}, auto_envvar_prefix="ANTA") except Exception as e: # pylint: disable=broad-exception-caught diff --git a/anta/cli/check/__init__.py b/anta/cli/check/__init__.py index aec80aa45..bbc5a7e9d 100644 --- a/anta/cli/check/__init__.py +++ b/anta/cli/check/__init__.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands to validate configuration files -""" +"""Click commands to validate configuration files.""" + import click from anta.cli.check import commands @@ -11,7 +10,7 @@ @click.group def check() -> None: - """Commands to validate configuration files""" + """Commands to validate configuration files.""" check.add_command(commands.catalog) diff --git a/anta/cli/check/commands.py b/anta/cli/check/commands.py index 8208d6459..23895d73c 100644 --- a/anta/cli/check/commands.py +++ b/anta/cli/check/commands.py @@ -2,28 +2,28 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name -""" -Click commands to validate configuration files -""" +"""Click commands to validate configuration files.""" + from __future__ import annotations import logging +from typing import TYPE_CHECKING import click from rich.pretty import pretty_repr -from anta.catalog import AntaCatalog from anta.cli.console import console from anta.cli.utils import catalog_options +if TYPE_CHECKING: + from anta.catalog import AntaCatalog + logger = logging.getLogger(__name__) @click.command @catalog_options def catalog(catalog: AntaCatalog) -> None: - """ - Check that the catalog is valid - """ + """Check that the catalog is valid.""" console.print(f"[bold][green]Catalog is valid: {catalog.filename}") console.print(pretty_repr(catalog.tests)) diff --git a/anta/cli/console.py b/anta/cli/console.py index cf4e6fb38..9c57d6d64 100644 --- a/anta/cli/console.py +++ b/anta/cli/console.py @@ -1,9 +1,9 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -ANTA Top-level Console -https://rich.readthedocs.io/en/stable/console.html#console-api +"""ANTA Top-level Console. + +https://rich.readthedocs.io/en/stable/console.html#console-api. """ from rich.console import Console diff --git a/anta/cli/debug/__init__.py b/anta/cli/debug/__init__.py index 6c4dbfba3..18d577fe8 100644 --- a/anta/cli/debug/__init__.py +++ b/anta/cli/debug/__init__.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands to execute EOS commands on remote devices -""" +"""Click commands to execute EOS commands on remote devices.""" + import click from anta.cli.debug import commands @@ -11,7 +10,7 @@ @click.group def debug() -> None: - """Commands to execute EOS commands on remote devices""" + """Commands to execute EOS commands on remote devices.""" debug.add_command(commands.run_cmd) diff --git a/anta/cli/debug/commands.py b/anta/cli/debug/commands.py index 7fffc2ef0..562cd9f0b 100644 --- a/anta/cli/debug/commands.py +++ b/anta/cli/debug/commands.py @@ -2,23 +2,24 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name -""" -Click commands to execute EOS commands on remote devices -""" +"""Click commands to execute EOS commands on remote devices.""" + from __future__ import annotations import asyncio import logging -from typing import Literal +from typing import TYPE_CHECKING, Literal import click from anta.cli.console import console from anta.cli.debug.utils import debug_options from anta.cli.utils import ExitCode -from anta.device import AntaDevice from anta.models import AntaCommand, AntaTemplate +if TYPE_CHECKING: + from anta.device import AntaDevice + logger = logging.getLogger(__name__) @@ -26,8 +27,15 @@ @debug_options @click.pass_context @click.option("--command", "-c", type=str, required=True, help="Command to run") -def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int) -> None: - """Run arbitrary command to an ANTA device""" +def run_cmd( + ctx: click.Context, + device: AntaDevice, + command: str, + ofmt: Literal["json", "text"], + version: Literal["1", "latest"], + revision: int, +) -> None: + """Run arbitrary command to an ANTA device.""" console.print(f"Run command [green]{command}[/green] on [red]{device.name}[/red]") # I do not assume the following line, but click make me do it v: Literal[1, "latest"] = version if version == "latest" else 1 @@ -45,18 +53,32 @@ def run_cmd(ctx: click.Context, device: AntaDevice, command: str, ofmt: Literal[ @click.command @debug_options @click.pass_context -@click.option("--template", "-t", type=str, required=True, help="Command template to run. E.g. 'show vlan {vlan_id}'") +@click.option( + "--template", + "-t", + type=str, + required=True, + help="Command template to run. E.g. 'show vlan {vlan_id}'", +) @click.argument("params", required=True, nargs=-1) def run_template( - ctx: click.Context, device: AntaDevice, template: str, params: list[str], ofmt: Literal["json", "text"], version: Literal["1", "latest"], revision: int + ctx: click.Context, + device: AntaDevice, + template: str, + params: list[str], + ofmt: Literal["json", "text"], + version: Literal["1", "latest"], + revision: int, ) -> None: # pylint: disable=too-many-arguments """Run arbitrary templated command to an ANTA device. Takes a list of arguments (keys followed by a value) to build a dictionary used as template parameters. - Example: + Example: + ------- anta debug run-template -d leaf1a -t 'show vlan {vlan_id}' vlan_id 1 + """ template_params = dict(zip(params[::2], params[1::2])) @@ -64,7 +86,7 @@ def run_template( # I do not assume the following line, but click make me do it v: Literal[1, "latest"] = version if version == "latest" else 1 t = AntaTemplate(template=template, ofmt=ofmt, version=v, revision=revision) - c = t.render(**template_params) # type: ignore + c = t.render(**template_params) # type: ignore[arg-type] asyncio.run(device.collect(c)) if not c.collected: console.print(f"[bold red] Command '{c.command}' failed to execute!") diff --git a/anta/cli/debug/utils.py b/anta/cli/debug/utils.py index cc2193d03..aab929fe4 100644 --- a/anta/cli/debug/utils.py +++ b/anta/cli/debug/utils.py @@ -1,35 +1,56 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Utils functions to use with anta.cli.debug module. -""" +"""Utils functions to use with anta.cli.debug module.""" + from __future__ import annotations import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any, Callable import click from anta.cli.utils import ExitCode, inventory_options -from anta.inventory import AntaInventory + +if TYPE_CHECKING: + from anta.inventory import AntaInventory logger = logging.getLogger(__name__) -def debug_options(f: Any) -> Any: - """Click common options required to execute a command on a specific device""" +def debug_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options required to execute a command on a specific device.""" @inventory_options - @click.option("--ofmt", type=click.Choice(["json", "text"]), default="json", help="EOS eAPI format to use. can be text or json") - @click.option("--version", "-v", type=click.Choice(["1", "latest"]), default="latest", help="EOS eAPI version") + @click.option( + "--ofmt", + type=click.Choice(["json", "text"]), + default="json", + help="EOS eAPI format to use. can be text or json", + ) + @click.option( + "--version", + "-v", + type=click.Choice(["1", "latest"]), + default="latest", + help="EOS eAPI version", + ) @click.option("--revision", "-r", type=int, help="eAPI command revision", required=False) @click.option("--device", "-d", type=str, required=True, help="Device from inventory to use") @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, *args: tuple[Any], inventory: AntaInventory, tags: list[str] | None, device: str, **kwargs: dict[str, Any]) -> Any: + def wrapper( + ctx: click.Context, + *args: tuple[Any], + inventory: AntaInventory, + tags: list[str] | None, + device: str, + **kwargs: Any, + ) -> Any: + # TODO: @gmuloc - tags come from context https://github.com/arista-netdevops-community/anta/issues/584 # pylint: disable=unused-argument + # ruff: noqa: ARG001 try: d = inventory[device] except KeyError as e: diff --git a/anta/cli/exec/__init__.py b/anta/cli/exec/__init__.py index 6be39343b..7f9b4c2b6 100644 --- a/anta/cli/exec/__init__.py +++ b/anta/cli/exec/__init__.py @@ -1,19 +1,18 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands to execute various scripts on EOS devices -""" +"""Click commands to execute various scripts on EOS devices.""" + import click from anta.cli.exec import commands -@click.group -def exec() -> None: # pylint: disable=redefined-builtin - """Commands to execute various scripts on EOS devices""" +@click.group("exec") +def _exec() -> None: # pylint: disable=redefined-builtin + """Commands to execute various scripts on EOS devices.""" -exec.add_command(commands.clear_counters) -exec.add_command(commands.snapshot) -exec.add_command(commands.collect_tech_support) +_exec.add_command(commands.clear_counters) +_exec.add_command(commands.snapshot) +_exec.add_command(commands.collect_tech_support) diff --git a/anta/cli/exec/commands.py b/anta/cli/exec/commands.py index 8b80d1989..971614cc2 100644 --- a/anta/cli/exec/commands.py +++ b/anta/cli/exec/commands.py @@ -1,23 +1,26 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands to execute various scripts on EOS devices -""" +"""Click commands to execute various scripts on EOS devices.""" + from __future__ import annotations import asyncio import logging import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path +from typing import TYPE_CHECKING import click from yaml import safe_load +from anta.cli.console import console from anta.cli.exec.utils import clear_counters_utils, collect_commands, collect_scheduled_show_tech from anta.cli.utils import inventory_options -from anta.inventory import AntaInventory + +if TYPE_CHECKING: + from anta.inventory import AntaInventory logger = logging.getLogger(__name__) @@ -25,7 +28,7 @@ @click.command @inventory_options def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None: - """Clear counter statistics on EOS devices""" + """Clear counter statistics on EOS devices.""" asyncio.run(clear_counters_utils(inventory, tags=tags)) @@ -45,27 +48,40 @@ def clear_counters(inventory: AntaInventory, tags: list[str] | None) -> None: show_envvar=True, type=click.Path(file_okay=False, dir_okay=True, exists=False, writable=True, path_type=Path), help="Directory to save commands output.", - default=f"anta_snapshot_{datetime.now().strftime('%Y-%m-%d_%H_%M_%S')}", + default=f"anta_snapshot_{datetime.now(tz=timezone.utc).astimezone().strftime('%Y-%m-%d_%H_%M_%S')}", show_default=True, ) def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Path, output: Path) -> None: - """Collect commands output from devices in inventory""" - print(f"Collecting data for {commands_list}") - print(f"Output directory is {output}") + """Collect commands output from devices in inventory.""" + console.print(f"Collecting data for {commands_list}") + console.print(f"Output directory is {output}") try: - with open(commands_list, "r", encoding="UTF-8") as file: + with commands_list.open(encoding="UTF-8") as file: file_content = file.read() eos_commands = safe_load(file_content) except FileNotFoundError: - logger.error(f"Error reading {commands_list}") + logger.error("Error reading %s", commands_list) sys.exit(1) asyncio.run(collect_commands(inventory, eos_commands, output, tags=tags)) @click.command() @inventory_options -@click.option("--output", "-o", default="./tech-support", show_default=True, help="Path for test catalog", type=click.Path(path_type=Path), required=False) -@click.option("--latest", help="Number of scheduled show-tech to retrieve", type=int, required=False) +@click.option( + "--output", + "-o", + default="./tech-support", + show_default=True, + help="Path for test catalog", + type=click.Path(path_type=Path), + required=False, +) +@click.option( + "--latest", + help="Number of scheduled show-tech to retrieve", + type=int, + required=False, +) @click.option( "--configure", help="Ensure devices have 'aaa authorization exec default local' configured (required for SCP on EOS). THIS WILL CHANGE THE CONFIGURATION OF YOUR NETWORK.", @@ -73,6 +89,13 @@ def snapshot(inventory: AntaInventory, tags: list[str] | None, commands_list: Pa is_flag=True, show_default=True, ) -def collect_tech_support(inventory: AntaInventory, tags: list[str] | None, output: Path, latest: int | None, configure: bool) -> None: - """Collect scheduled tech-support from EOS devices""" - asyncio.run(collect_scheduled_show_tech(inventory, output, configure, tags=tags, latest=latest)) +def collect_tech_support( + inventory: AntaInventory, + tags: list[str] | None, + output: Path, + latest: int | None, + *, + configure: bool, +) -> None: + """Collect scheduled tech-support from EOS devices.""" + asyncio.run(collect_scheduled_show_tech(inventory, output, configure=configure, tags=tags, latest=latest)) diff --git a/anta/cli/exec/utils.py b/anta/cli/exec/utils.py index 681db1773..0537ee00d 100644 --- a/anta/cli/exec/utils.py +++ b/anta/cli/exec/utils.py @@ -2,9 +2,8 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Exec CLI helpers -""" +"""Exec CLI helpers.""" + from __future__ import annotations import asyncio @@ -13,24 +12,25 @@ import logging import re from pathlib import Path -from typing import Literal +from typing import TYPE_CHECKING, Literal from aioeapi import EapiCommandError +from click.exceptions import UsageError from httpx import ConnectError, HTTPError from anta.device import AntaDevice, AsyncEOSDevice -from anta.inventory import AntaInventory from anta.models import AntaCommand +if TYPE_CHECKING: + from anta.inventory import AntaInventory + EOS_SCHEDULED_TECH_SUPPORT = "/mnt/flash/schedule/tech-support" INVALID_CHAR = "`~!@#$/" logger = logging.getLogger(__name__) async def clear_counters_utils(anta_inventory: AntaInventory, tags: list[str] | None = None) -> None: - """ - Clear counters - """ + """Clear counters.""" async def clear(dev: AntaDevice) -> None: commands = [AntaCommand(command="clear counters")] @@ -39,8 +39,8 @@ async def clear(dev: AntaDevice) -> None: await dev.collect_commands(commands=commands) for command in commands: if not command.collected: - logger.error(f"Could not clear counters on device {dev.name}: {command.errors}") - logger.info(f"Cleared counters on {dev.name} ({dev.hw_model})") + logger.error("Could not clear counters on device %s: %s", dev.name, command.errors) + logger.info("Cleared counters on %s (%s)", dev.name, dev.hw_model) logger.info("Connecting to devices...") await anta_inventory.connect_inventory() @@ -55,9 +55,7 @@ async def collect_commands( root_dir: Path, tags: list[str] | None = None, ) -> None: - """ - Collect EOS commands - """ + """Collect EOS commands.""" async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "text"]) -> None: outdir = Path() / root_dir / dev.name / outformat @@ -66,7 +64,7 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex c = AntaCommand(command=command, ofmt=outformat) await dev.collect(c) if not c.collected: - logger.error(f"Could not collect commands on device {dev.name}: {c.errors}") + logger.error("Could not collect commands on device %s: %s", dev.name, c.errors) return if c.ofmt == "json": outfile = outdir / f"{safe_command}.json" @@ -76,7 +74,7 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex content = c.text_output with outfile.open(mode="w", encoding="UTF-8") as f: f.write(content) - logger.info(f"Collected command '{command}' from device {dev.name} ({dev.hw_model})") + logger.info("Collected command '%s' from device %s (%s)", command, dev.name, dev.hw_model) logger.info("Connecting to devices...") await inv.connect_inventory() @@ -90,18 +88,14 @@ async def collect(dev: AntaDevice, command: str, outformat: Literal["json", "tex res = await asyncio.gather(*coros, return_exceptions=True) for r in res: if isinstance(r, Exception): - logger.error(f"Error when collecting commands: {str(r)}") + logger.error("Error when collecting commands: %s", str(r)) -async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, configure: bool, tags: list[str] | None = None, latest: int | None = None) -> None: - """ - Collect scheduled show-tech on devices - """ +async def collect_scheduled_show_tech(inv: AntaInventory, root_dir: Path, *, configure: bool, tags: list[str] | None = None, latest: int | None = None) -> None: + """Collect scheduled show-tech on devices.""" async def collect(device: AntaDevice) -> None: - """ - Collect all the tech-support files stored on Arista switches flash and copy them locally - """ + """Collect all the tech-support files stored on Arista switches flash and copy them locally.""" try: # Get the tech-support filename to retrieve cmd = f"bash timeout 10 ls -1t {EOS_SCHEDULED_TECH_SUPPORT}" @@ -110,9 +104,9 @@ async def collect(device: AntaDevice) -> None: command = AntaCommand(command=cmd, ofmt="text") await device.collect(command=command) if command.collected and command.text_output: - filenames = list(map(lambda f: Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}"), command.text_output.splitlines())) + filenames = [Path(f"{EOS_SCHEDULED_TECH_SUPPORT}/{f}") for f in command.text_output.splitlines()] else: - logger.error(f"Unable to get tech-support filenames on {device.name}: verify that {EOS_SCHEDULED_TECH_SUPPORT} is not empty") + logger.error("Unable to get tech-support filenames on %s: verify that %s is not empty", device.name, EOS_SCHEDULED_TECH_SUPPORT) return # Create directories @@ -124,12 +118,15 @@ async def collect(device: AntaDevice) -> None: await device.collect(command=command) if command.collected and not command.text_output: - logger.debug(f"'aaa authorization exec default local' is not configured on device {device.name}") + logger.debug("'aaa authorization exec default local' is not configured on device %s", device.name) if configure: - # Otherwise mypy complains about enable - assert isinstance(device, AsyncEOSDevice) - # TODO - @mtache - add `config` field to `AntaCommand` object to handle this use case. commands = [] + # TODO: @mtache - add `config` field to `AntaCommand` object to handle this use case. + # Otherwise mypy complains about enable as it is only implemented for AsyncEOSDevice + # TODO: Should enable be also included in AntaDevice? + if not isinstance(device, AsyncEOSDevice): + msg = "anta exec collect-tech-support is only supported with AsyncEOSDevice for now." + raise UsageError(msg) if device.enable and device._enable_password is not None: # pylint: disable=protected-access commands.append({"cmd": "enable", "input": device._enable_password}) # pylint: disable=protected-access elif device.enable: @@ -138,22 +135,22 @@ async def collect(device: AntaDevice) -> None: [ {"cmd": "configure terminal"}, {"cmd": "aaa authorization exec default local"}, - ] + ], ) - logger.warning(f"Configuring 'aaa authorization exec default local' on device {device.name}") + logger.warning("Configuring 'aaa authorization exec default local' on device %s", device.name) command = AntaCommand(command="show running-config | include aaa authorization exec default local", ofmt="text") await device._session.cli(commands=commands) # pylint: disable=protected-access - logger.info(f"Configured 'aaa authorization exec default local' on device {device.name}") + logger.info("Configured 'aaa authorization exec default local' on device %s", device.name) else: - logger.error(f"Unable to collect tech-support on {device.name}: configuration 'aaa authorization exec default local' is not present") + logger.error("Unable to collect tech-support on %s: configuration 'aaa authorization exec default local' is not present", device.name) return - logger.debug(f"'aaa authorization exec default local' is already configured on device {device.name}") + logger.debug("'aaa authorization exec default local' is already configured on device %s", device.name) await device.copy(sources=filenames, destination=outdir, direction="from") - logger.info(f"Collected {len(filenames)} scheduled tech-support from {device.name}") + logger.info("Collected %s scheduled tech-support from %s", len(filenames), device.name) except (EapiCommandError, HTTPError, ConnectError) as e: - logger.error(f"Unable to collect tech-support on {device.name}: {str(e)}") + logger.error("Unable to collect tech-support on %s: %s", device.name, str(e)) logger.info("Connecting to devices...") await inv.connect_inventory() diff --git a/anta/cli/get/__init__.py b/anta/cli/get/__init__.py index d82200882..abc7b3893 100644 --- a/anta/cli/get/__init__.py +++ b/anta/cli/get/__init__.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands to get information from or generate inventories -""" +"""Click commands to get information from or generate inventories.""" + import click from anta.cli.get import commands @@ -11,7 +10,7 @@ @click.group def get() -> None: - """Commands to get information from or generate inventories""" + """Commands to get information from or generate inventories.""" get.add_command(commands.from_cvp) diff --git a/anta/cli/get/commands.py b/anta/cli/get/commands.py index b0fe76f99..07e8eeb10 100644 --- a/anta/cli/get/commands.py +++ b/anta/cli/get/commands.py @@ -2,15 +2,15 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable = redefined-outer-name -""" -Click commands to get information from or generate inventories -""" +"""Click commands to get information from or generate inventories.""" + from __future__ import annotations import asyncio import json import logging from pathlib import Path +from typing import TYPE_CHECKING import click from cvprac.cvp_client import CvpClient @@ -20,10 +20,12 @@ from anta.cli.console import console from anta.cli.get.utils import inventory_output_options from anta.cli.utils import ExitCode, inventory_options -from anta.inventory import AntaInventory from .utils import create_inventory_from_ansible, create_inventory_from_cvp, get_cv_token +if TYPE_CHECKING: + from anta.inventory import AntaInventory + logger = logging.getLogger(__name__) @@ -35,30 +37,29 @@ @click.option("--password", "-p", help="CloudVision password", type=str, required=True) @click.option("--container", "-c", help="CloudVision container where devices are configured", type=str) def from_cvp(ctx: click.Context, output: Path, host: str, username: str, password: str, container: str | None) -> None: - """ - Build ANTA inventory from Cloudvision + """Build ANTA inventory from Cloudvision. TODO - handle get_inventory and get_devices_in_container failure """ - logger.info(f"Getting authentication token for user '{username}' from CloudVision instance '{host}'") + logger.info("Getting authentication token for user '%s' from CloudVision instance '%s'", username, host) token = get_cv_token(cvp_ip=host, cvp_username=username, cvp_password=password) clnt = CvpClient() try: clnt.connect(nodes=[host], username="", password="", api_token=token) except CvpApiError as error: - logger.error(f"Error connecting to CloudVision: {error}") + logger.error("Error connecting to CloudVision: %s", error) ctx.exit(ExitCode.USAGE_ERROR) - logger.info(f"Connected to CloudVision instance '{host}'") + logger.info("Connected to CloudVision instance '%s'", host) cvp_inventory = None if container is None: # Get a list of all devices - logger.info(f"Getting full inventory from CloudVision instance '{host}'") + logger.info("Getting full inventory from CloudVision instance '%s'", host) cvp_inventory = clnt.api.get_inventory() else: # Get devices under a container - logger.info(f"Getting inventory for container {container} from CloudVision instance '{host}'") + logger.info("Getting inventory for container %s from CloudVision instance '%s'", container, host) cvp_inventory = clnt.api.get_devices_in_container(container) create_inventory_from_cvp(cvp_inventory, output) @@ -74,8 +75,8 @@ def from_cvp(ctx: click.Context, output: Path, host: str, username: str, passwor required=True, ) def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_inventory: Path) -> None: - """Build ANTA inventory from an ansible inventory YAML file""" - logger.info(f"Building inventory from ansible file '{ansible_inventory}'") + """Build ANTA inventory from an ansible inventory YAML file.""" + logger.info("Building inventory from ansible file '%s'", ansible_inventory) try: create_inventory_from_ansible( inventory=ansible_inventory, @@ -90,10 +91,11 @@ def from_ansible(ctx: click.Context, output: Path, ansible_group: str, ansible_i @click.command @inventory_options @click.option("--connected/--not-connected", help="Display inventory after connection has been created", default=False, required=False) -def inventory(inventory: AntaInventory, tags: list[str] | None, connected: bool) -> None: +def inventory(inventory: AntaInventory, tags: list[str] | None, *, connected: bool) -> None: """Show inventory loaded in ANTA.""" - - logger.debug(f"Requesting devices for tags: {tags}") + # TODO: @gmuloc - tags come from context - we cannot have everything.. + # ruff: noqa: ARG001 + logger.debug("Requesting devices for tags: %s", tags) console.print("Current inventory content is:", style="white on blue") if connected: diff --git a/anta/cli/get/utils.py b/anta/cli/get/utils.py index 179da0cb0..4d9d99b62 100644 --- a/anta/cli/get/utils.py +++ b/anta/cli/get/utils.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Utils functions to use with anta.cli.get.commands module. -""" +"""Utils functions to use with anta.cli.get.commands module.""" + from __future__ import annotations import functools @@ -11,7 +10,7 @@ import logging from pathlib import Path from sys import stdin -from typing import Any +from typing import Any, Callable import click import requests @@ -27,8 +26,8 @@ logger = logging.getLogger(__name__) -def inventory_output_options(f: Any) -> Any: - """Click common options required when an inventory is being generated""" +def inventory_output_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options required when an inventory is being generated.""" @click.option( "--output", @@ -50,7 +49,13 @@ def inventory_output_options(f: Any) -> Any: ) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool, **kwargs: dict[str, Any]) -> Any: + def wrapper( + ctx: click.Context, + *args: tuple[Any], + output: Path, + overwrite: bool, + **kwargs: dict[str, Any], + ) -> Any: # Boolean to check if the file is empty output_is_not_empty = output.exists() and output.stat().st_size != 0 # Check overwrite when file is not empty @@ -58,7 +63,10 @@ def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool is_tty = stdin.isatty() if is_tty: # File has content and it is in an interactive TTY --> Prompt user - click.confirm(f"Your destination file '{output}' is not empty, continue?", abort=True) + click.confirm( + f"Your destination file '{output}' is not empty, continue?", + abort=True, + ) else: # File has content and it is not interactive TTY nor overwrite set to True --> execution stop logger.critical("Conversion aborted since destination file is not empty (not running in interactive TTY)") @@ -70,84 +78,94 @@ def wrapper(ctx: click.Context, *args: tuple[Any], output: Path, overwrite: bool def get_cv_token(cvp_ip: str, cvp_username: str, cvp_password: str) -> str: - """Generate AUTH token from CVP using password""" - # TODO, need to handle requests eror + """Generate AUTH token from CVP using password.""" + # TODO: need to handle requests eror # use CVP REST API to generate a token - URL = f"https://{cvp_ip}/cvpservice/login/authenticate.do" + url = f"https://{cvp_ip}/cvpservice/login/authenticate.do" payload = json.dumps({"userId": cvp_username, "password": cvp_password}) headers = {"Content-Type": "application/json", "Accept": "application/json"} - response = requests.request("POST", URL, headers=headers, data=payload, verify=False, timeout=10) + response = requests.request("POST", url, headers=headers, data=payload, verify=False, timeout=10) return response.json()["sessionId"] def write_inventory_to_file(hosts: list[AntaInventoryHost], output: Path) -> None: - """Write a file inventory from pydantic models""" + """Write a file inventory from pydantic models.""" i = AntaInventoryInput(hosts=hosts) - with open(output, "w", encoding="UTF-8") as out_fd: + with output.open(mode="w", encoding="UTF-8") as out_fd: out_fd.write(yaml.dump({AntaInventory.INVENTORY_ROOT_KEY: i.model_dump(exclude_unset=True)})) - logger.info(f"ANTA inventory file has been created: '{output}'") + logger.info("ANTA inventory file has been created: '%s'", output) def create_inventory_from_cvp(inv: list[dict[str, Any]], output: Path) -> None: - """ - Create an inventory file from Arista CloudVision inventory - """ - logger.debug(f"Received {len(inv)} device(s) from CloudVision") + """Create an inventory file from Arista CloudVision inventory.""" + logger.debug("Received %s device(s) from CloudVision", len(inv)) hosts = [] for dev in inv: - logger.info(f" * adding entry for {dev['hostname']}") - hosts.append(AntaInventoryHost(name=dev["hostname"], host=dev["ipAddress"], tags=[dev["containerName"].lower()])) + logger.info(" * adding entry for %s", dev["hostname"]) + hosts.append( + AntaInventoryHost( + name=dev["hostname"], + host=dev["ipAddress"], + tags=[dev["containerName"].lower()], + ) + ) write_inventory_to_file(hosts, output) +def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None: + """Retrieve Ansible group from an input data dict.""" + for k, v in data.items(): + if isinstance(v, dict): + if k == group and ("children" in v or "hosts" in v): + return v + d = find_ansible_group(v, group) + if d is not None: + return d + return None + + +def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]: + """Deep parsing of YAML file to extract hosts and associated IPs.""" + if hosts is None: + hosts = [] + for key, value in data.items(): + if isinstance(value, dict) and "ansible_host" in value: + logger.info(" * adding entry for %s", key) + hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"])) + elif isinstance(value, dict): + deep_yaml_parsing(value, hosts) + else: + return hosts + return hosts + + def create_inventory_from_ansible(inventory: Path, output: Path, ansible_group: str = "all") -> None: - """ - Create an ANTA inventory from an Ansible inventory YAML file + """Create an ANTA inventory from an Ansible inventory YAML file. Args: + ---- inventory: Ansible Inventory file to read output: ANTA inventory file to generate. ansible_group: Ansible group from where to extract data. - """ - - def find_ansible_group(data: dict[str, Any], group: str) -> dict[str, Any] | None: - for k, v in data.items(): - if isinstance(v, dict): - if k == group and ("children" in v.keys() or "hosts" in v.keys()): - return v - d = find_ansible_group(v, group) - if d is not None: - return d - return None - - def deep_yaml_parsing(data: dict[str, Any], hosts: list[AntaInventoryHost] | None = None) -> list[AntaInventoryHost]: - """Deep parsing of YAML file to extract hosts and associated IPs""" - if hosts is None: - hosts = [] - for key, value in data.items(): - if isinstance(value, dict) and "ansible_host" in value.keys(): - logger.info(f" * adding entry for {key}") - hosts.append(AntaInventoryHost(name=key, host=value["ansible_host"])) - elif isinstance(value, dict): - deep_yaml_parsing(value, hosts) - else: - return hosts - return hosts + """ try: - with open(inventory, encoding="utf-8") as inv: + with inventory.open(encoding="utf-8") as inv: ansible_inventory = yaml.safe_load(inv) except OSError as exc: - raise ValueError(f"Could not parse {inventory}.") from exc + msg = f"Could not parse {inventory}." + raise ValueError(msg) from exc if not ansible_inventory: - raise ValueError(f"Ansible inventory {inventory} is empty") + msg = f"Ansible inventory {inventory} is empty" + raise ValueError(msg) ansible_inventory = find_ansible_group(ansible_inventory, ansible_group) if ansible_inventory is None: - raise ValueError(f"Group {ansible_group} not found in Ansible inventory") + msg = f"Group {ansible_group} not found in Ansible inventory" + raise ValueError(msg) ansible_hosts = deep_yaml_parsing(ansible_inventory) write_inventory_to_file(ansible_hosts, output) diff --git a/anta/cli/nrfu/__init__.py b/anta/cli/nrfu/__init__.py index 2297bc2eb..e9a2f51b6 100644 --- a/anta/cli/nrfu/__init__.py +++ b/anta/cli/nrfu/__init__.py @@ -1,38 +1,39 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands that run ANTA tests using anta.runner -""" +"""Click commands that run ANTA tests using anta.runner.""" + from __future__ import annotations import asyncio +from typing import TYPE_CHECKING import click -from anta.catalog import AntaCatalog from anta.cli.nrfu import commands from anta.cli.utils import AliasedGroup, catalog_options, inventory_options -from anta.inventory import AntaInventory from anta.models import AntaTest from anta.result_manager import ResultManager from anta.runner import main from .utils import anta_progress_bar, print_settings +if TYPE_CHECKING: + from anta.catalog import AntaCatalog + from anta.inventory import AntaInventory + class IgnoreRequiredWithHelp(AliasedGroup): - """ + """Custom Click Group. + https://stackoverflow.com/questions/55818737/python-click-application-required-parameters-have-precedence-over-sub-command-he + Solution to allow help without required options on subcommand - This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734 + This is not planned to be fixed in click as per: https://github.com/pallets/click/issues/295#issuecomment-708129734. """ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: - """ - Ignore MissingParameter exception when parsing arguments if `--help` - is present for a subcommand - """ + """Ignore MissingParameter exception when parsing arguments if `--help` is present for a subcommand.""" # Adding a flag for potential callbacks ctx.ensure_object(dict) if "--help" in args: @@ -57,8 +58,8 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: @catalog_options @click.option("--ignore-status", help="Always exit with success", show_envvar=True, is_flag=True, default=False) @click.option("--ignore-error", help="Only report failures and not errors", show_envvar=True, is_flag=True, default=False) -def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, ignore_status: bool, ignore_error: bool) -> None: - """Run ANTA tests on devices""" +def nrfu(ctx: click.Context, inventory: AntaInventory, tags: list[str] | None, catalog: AntaCatalog, *, ignore_status: bool, ignore_error: bool) -> None: + """Run ANTA tests on devices.""" # If help is invoke somewhere, skip the command if ctx.obj.get("_anta_help"): return diff --git a/anta/cli/nrfu/commands.py b/anta/cli/nrfu/commands.py index a0acfc9ed..f96a6c831 100644 --- a/anta/cli/nrfu/commands.py +++ b/anta/cli/nrfu/commands.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Click commands that render ANTA tests results -""" +"""Click commands that render ANTA tests results.""" + from __future__ import annotations import logging @@ -23,10 +22,14 @@ @click.option("--device", "-d", help="Show a summary for this device", type=str, required=False) @click.option("--test", "-t", help="Show a summary for this test", type=str, required=False) @click.option( - "--group-by", default=None, type=click.Choice(["device", "test"], case_sensitive=False), help="Group result by test or host. default none", required=False + "--group-by", + default=None, + type=click.Choice(["device", "test"], case_sensitive=False), + help="Group result by test or host. default none", + required=False, ) def table(ctx: click.Context, device: str | None, test: str | None, group_by: str) -> None: - """ANTA command to check network states with table result""" + """ANTA command to check network states with table result.""" print_table(results=ctx.obj["result_manager"], device=device, group_by=group_by, test=test) exit_with_code(ctx) @@ -42,7 +45,7 @@ def table(ctx: click.Context, device: str | None, test: str | None, group_by: st help="Path to save report as a file", ) def json(ctx: click.Context, output: pathlib.Path | None) -> None: - """ANTA command to check network state with JSON result""" + """ANTA command to check network state with JSON result.""" print_json(results=ctx.obj["result_manager"], output=output) exit_with_code(ctx) @@ -51,8 +54,8 @@ def json(ctx: click.Context, output: pathlib.Path | None) -> None: @click.pass_context @click.option("--search", "-s", help="Regular expression to search in both name and test", type=str, required=False) @click.option("--skip-error", help="Hide tests in errors due to connectivity issue", default=False, is_flag=True, show_default=True, required=False) -def text(ctx: click.Context, search: str | None, skip_error: bool) -> None: - """ANTA command to check network states with text result""" +def text(ctx: click.Context, search: str | None, *, skip_error: bool) -> None: + """ANTA command to check network states with text result.""" print_text(results=ctx.obj["result_manager"], search=search, skip_error=skip_error) exit_with_code(ctx) @@ -76,6 +79,6 @@ def text(ctx: click.Context, search: str | None, skip_error: bool) -> None: help="Path to save report as a file", ) def tpl_report(ctx: click.Context, template: pathlib.Path, output: pathlib.Path | None) -> None: - """ANTA command to check network state with templated report""" + """ANTA command to check network state with templated report.""" print_jinja(results=ctx.obj["result_manager"], template=template, output=output) exit_with_code(ctx) diff --git a/anta/cli/nrfu/utils.py b/anta/cli/nrfu/utils.py index 87b89cff2..d7884afe3 100644 --- a/anta/cli/nrfu/utils.py +++ b/anta/cli/nrfu/utils.py @@ -1,26 +1,29 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Utils functions to use with anta.cli.nrfu.commands module. -""" +"""Utils functions to use with anta.cli.nrfu.commands module.""" + from __future__ import annotations import json import logging -import pathlib import re +from typing import TYPE_CHECKING import rich from rich.panel import Panel from rich.pretty import pprint from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn -from anta.catalog import AntaCatalog from anta.cli.console import console -from anta.inventory import AntaInventory from anta.reporter import ReportJinja, ReportTable -from anta.result_manager import ResultManager + +if TYPE_CHECKING: + import pathlib + + from anta.catalog import AntaCatalog + from anta.inventory import AntaInventory + from anta.result_manager import ResultManager logger = logging.getLogger(__name__) @@ -29,14 +32,14 @@ def print_settings( inventory: AntaInventory, catalog: AntaCatalog, ) -> None: - """Print ANTA settings before running tests""" + """Print ANTA settings before running tests.""" message = f"Running ANTA tests:\n- {inventory}\n- Tests catalog contains {len(catalog.tests)} tests" console.print(Panel.fit(message, style="cyan", title="[green]Settings")) console.print() def print_table(results: ResultManager, device: str | None = None, test: str | None = None, group_by: str | None = None) -> None: - """Print result in a table""" + """Print result in a table.""" reporter = ReportTable() console.print() if device: @@ -52,32 +55,32 @@ def print_table(results: ResultManager, device: str | None = None, test: str | N def print_json(results: ResultManager, output: pathlib.Path | None = None) -> None: - """Print result in a json format""" + """Print result in a json format.""" console.print() console.print(Panel("JSON results of all tests", style="cyan")) rich.print_json(results.get_json_results()) if output is not None: - with open(output, "w", encoding="utf-8") as fout: + with output.open(mode="w", encoding="utf-8") as fout: fout.write(results.get_json_results()) def print_list(results: ResultManager, output: pathlib.Path | None = None) -> None: - """Print result in a list""" + """Print result in a list.""" console.print() console.print(Panel.fit("List results of all tests", style="cyan")) pprint(results.get_results()) if output is not None: - with open(output, "w", encoding="utf-8") as fout: + with output.open(mode="w", encoding="utf-8") as fout: fout.write(str(results.get_results())) -def print_text(results: ResultManager, search: str | None = None, skip_error: bool = False) -> None: - """Print results as simple text""" +def print_text(results: ResultManager, search: str | None = None, *, skip_error: bool = False) -> None: + """Print results as simple text.""" console.print() regexp = re.compile(search or ".*") for line in results.get_results(): if any(regexp.match(entry) for entry in [line.name, line.test]) and (not skip_error or line.result != "error"): - message = f" ({str(line.messages[0])})" if len(line.messages) > 0 else "" + message = f" ({line.messages[0]!s})" if len(line.messages) > 0 else "" console.print(f"{line.name} :: {line.test} :: [{line.result}]{line.result.upper()}[/{line.result}]{message}", highlight=False) @@ -89,13 +92,13 @@ def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib. report = reporter.render(json_data) console.print(report) if output is not None: - with open(output, "w", encoding="utf-8") as file: + with output.open(mode="w", encoding="utf-8") as file: file.write(report) # Adding our own ANTA spinner - overriding rich SPINNERS for our own # so ignore warning for redefinition -rich.spinner.SPINNERS = { # type: ignore[attr-defined] # noqa: F811 +rich.spinner.SPINNERS = { # type: ignore[attr-defined] "anta": { "interval": 150, "frames": [ @@ -112,14 +115,12 @@ def print_jinja(results: ResultManager, template: pathlib.Path, output: pathlib. "( 🐌 )", "( 🐌)", ], - } + }, } def anta_progress_bar() -> Progress: - """ - Return a customized Progress for progress bar - """ + """Return a customized Progress for progress bar.""" return Progress( SpinnerColumn("anta"), TextColumn("•"), diff --git a/anta/cli/utils.py b/anta/cli/utils.py index 97a0862df..1e5dd8f23 100644 --- a/anta/cli/utils.py +++ b/anta/cli/utils.py @@ -1,16 +1,15 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Utils functions to use with anta.cli module. -""" +"""Utils functions to use with anta.cli module.""" + from __future__ import annotations import enum import functools import logging from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable import click from pydantic import ValidationError @@ -18,7 +17,7 @@ from anta.catalog import AntaCatalog from anta.inventory import AntaInventory -from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError +from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError if TYPE_CHECKING: from click import Option @@ -27,10 +26,7 @@ class ExitCode(enum.IntEnum): - """ - Encodes the valid exit codes by anta - inspired from pytest - """ + """Encodes the valid exit codes by anta inspired from pytest.""" # Tests passed. OK = 0 @@ -46,17 +42,16 @@ class ExitCode(enum.IntEnum): def parse_tags(ctx: click.Context, param: Option, value: str) -> list[str] | None: # pylint: disable=unused-argument - """ - Click option callback to parse an ANTA inventory tags - """ + # ruff: noqa: ARG001 + """Click option callback to parse an ANTA inventory tags.""" if value is not None: return value.split(",") if "," in value else [value] return None def exit_with_code(ctx: click.Context) -> None: - """ - Exit the Click application with an exit code. + """Exit the Click application with an exit code. + This function determines the global test status to be either `unset`, `skipped`, `success` or `error` from the `ResultManger` instance. If flag `ignore_error` is set, the `error` status will be ignored in all the tests. @@ -64,10 +59,12 @@ def exit_with_code(ctx: click.Context) -> None: Exit the application with the following exit code: * 0 if `ignore_status` is `True` or global test status is `unset`, `skipped` or `success` * 1 if status is `failure` - * 2 if status is `error` + * 2 if status is `error`. Args: + ---- ctx: Click Context + """ if ctx.obj.get("ignore_status"): ctx.exit(ExitCode.OK) @@ -83,18 +80,19 @@ def exit_with_code(ctx: click.Context) -> None: ctx.exit(ExitCode.TESTS_ERROR) logger.error("Please gather logs and open an issue on Github.") - raise ValueError(f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github.") + msg = f"Unknown status returned by the ResultManager: {status}. Please gather logs and open an issue on Github." + raise ValueError(msg) class AliasedGroup(click.Group): - """ - Implements a subclass of Group that accepts a prefix for a command. + """Implements a subclass of Group that accepts a prefix for a command. + If there were a command called push, it would accept pus as an alias (so long as it was unique) - From Click documentation + From Click documentation. """ def get_command(self, ctx: click.Context, cmd_name: str) -> Any: - """Todo: document code""" + """Todo: document code.""" rv = click.Group.get_command(self, ctx, cmd_name) if rv is not None: return rv @@ -107,15 +105,17 @@ def get_command(self, ctx: click.Context, cmd_name: str) -> Any: return None def resolve_command(self, ctx: click.Context, args: Any) -> Any: - """Todo: document code""" + """Todo: document code.""" # always return the full command name _, cmd, args = super().resolve_command(ctx, args) - return cmd.name, cmd, args # type: ignore + if not cmd: + return None, None, None + return cmd.name, cmd, args # TODO: check code of click.pass_context that raise mypy errors for types and adapt this decorator -def inventory_options(f: Any) -> Any: - """Click common options when requiring an inventory to interact with devices""" +def inventory_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options when requiring an inventory to interact with devices.""" @click.option( "--username", @@ -174,7 +174,15 @@ def inventory_options(f: Any) -> Any: is_flag=True, show_default=True, ) - @click.option("--disable-cache", help="Disable cache globally", show_envvar=True, envvar="ANTA_DISABLE_CACHE", show_default=True, is_flag=True, default=False) + @click.option( + "--disable-cache", + help="Disable cache globally", + show_envvar=True, + envvar="ANTA_DISABLE_CACHE", + show_default=True, + is_flag=True, + default=False, + ) @click.option( "--inventory", "-i", @@ -218,17 +226,25 @@ def wrapper( if prompt: # User asked for a password prompt if password is None: - password = click.prompt("Please enter a password to connect to EOS", type=str, hide_input=True, confirmation_prompt=True) - if enable: - if enable_password is None: - if click.confirm("Is a password required to enter EOS privileged EXEC mode?"): - enable_password = click.prompt( - "Please enter a password to enter EOS privileged EXEC mode", type=str, hide_input=True, confirmation_prompt=True - ) + password = click.prompt( + "Please enter a password to connect to EOS", + type=str, + hide_input=True, + confirmation_prompt=True, + ) + if enable and enable_password is None and click.confirm("Is a password required to enter EOS privileged EXEC mode?"): + enable_password = click.prompt( + "Please enter a password to enter EOS privileged EXEC mode", + type=str, + hide_input=True, + confirmation_prompt=True, + ) if password is None: - raise click.BadParameter("EOS password needs to be provided by using either the '--password' option or the '--prompt' option.") + msg = "EOS password needs to be provided by using either the '--password' option or the '--prompt' option." + raise click.BadParameter(msg) if not enable and enable_password: - raise click.BadParameter("Providing a password to access EOS Privileged EXEC mode requires '--enable' option.") + msg = "Providing a password to access EOS Privileged EXEC mode requires '--enable' option." + raise click.BadParameter(msg) try: i = AntaInventory.parse( filename=inventory, @@ -240,15 +256,15 @@ def wrapper( insecure=insecure, disable_cache=disable_cache, ) - except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchema, InventoryRootKeyError): + except (ValidationError, TypeError, ValueError, YAMLError, OSError, InventoryIncorrectSchemaError, InventoryRootKeyError): ctx.exit(ExitCode.USAGE_ERROR) return f(*args, inventory=i, tags=tags, **kwargs) return wrapper -def catalog_options(f: Any) -> Any: - """Click common options when requiring a test catalog to execute ANTA tests""" +def catalog_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Click common options when requiring a test catalog to execute ANTA tests.""" @click.option( "--catalog", @@ -256,12 +272,23 @@ def catalog_options(f: Any) -> Any: envvar="ANTA_CATALOG", show_envvar=True, help="Path to the test catalog YAML file", - type=click.Path(file_okay=True, dir_okay=False, exists=True, readable=True, path_type=Path), + type=click.Path( + file_okay=True, + dir_okay=False, + exists=True, + readable=True, + path_type=Path, + ), required=True, ) @click.pass_context @functools.wraps(f) - def wrapper(ctx: click.Context, *args: tuple[Any], catalog: Path, **kwargs: dict[str, Any]) -> Any: + def wrapper( + ctx: click.Context, + *args: tuple[Any], + catalog: Path, + **kwargs: dict[str, Any], + ) -> Any: # If help is invoke somewhere, do not parse catalog if ctx.obj.get("_anta_help"): return f(*args, catalog=None, **kwargs) diff --git a/anta/custom_types.py b/anta/custom_types.py index f1b896b97..008a6e668 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 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 that provides predefined types for AntaTest.Input instances -""" +"""Module that provides predefined types for AntaTest.Input instances.""" + import re from typing import Literal @@ -13,7 +12,7 @@ def aaa_group_prefix(v: str) -> str: - """Prefix the AAA method with 'group' if it is known""" + """Prefix the AAA method with 'group' if it is known.""" built_in_methods = ["local", "none", "logging"] return f"group {v}" if v not in built_in_methods and not v.startswith("group ") else v @@ -24,11 +23,13 @@ def interface_autocomplete(v: str) -> str: Supported alias: - `et`, `eth` will be changed to `Ethernet` - `po` will be changed to `Port-Channel` - - `lo` will be changed to `Loopback`""" + - `lo` will be changed to `Loopback` + """ intf_id_re = re.compile(r"[0-9]+(\/[0-9]+)*(\.[0-9]+)?") m = intf_id_re.search(v) if m is None: - raise ValueError(f"Could not parse interface ID in interface '{v}'") + msg = f"Could not parse interface ID in interface '{v}'" + raise ValueError(msg) intf_id = m[0] alias_map = {"et": "Ethernet", "eth": "Ethernet", "po": "Port-Channel", "lo": "Loopback"} @@ -43,10 +44,12 @@ def interface_autocomplete(v: str) -> str: def interface_case_sensitivity(v: str) -> str: """Reformat interface name to match expected case sensitivity. - Examples: + Examples + -------- - ethernet -> Ethernet - vlan -> Vlan - loopback -> Loopback + """ if isinstance(v, str) and len(v) > 0 and not v[0].isupper(): return f"{v[0].upper()}{v[1:]}" @@ -54,13 +57,15 @@ def interface_case_sensitivity(v: str) -> str: def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: - """ - Abbreviations for different BGP multiprotocol capabilities. - Examples: + """Abbreviations for different BGP multiprotocol capabilities. + + Examples + -------- - IPv4 Unicast - L2vpnEVPN - ipv4 MPLS Labels - ipv4Mplsvpn + """ patterns = { r"\b(l2[\s\-]?vpn[\s\-]?evpn)\b": "l2VpnEvpn", @@ -121,3 +126,7 @@ def bgp_multiprotocol_capabilities_abbreviations(value: str) -> str: ] ErrDisableInterval = Annotated[int, Field(ge=30, le=86400)] Percent = Annotated[float, Field(ge=0.0, le=100.0)] +PositiveInteger = Annotated[int, Field(ge=0)] +Revision = Annotated[int, Field(ge=1, le=99)] +Hostname = Annotated[str, Field(pattern=r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")] +Port = Annotated[int, Field(ge=1, le=65535)] diff --git a/anta/decorators.py b/anta/decorators.py index 548f04a0f..f128f7914 100644 --- a/anta/decorators.py +++ b/anta/decorators.py @@ -2,40 +2,45 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """decorators for tests.""" + from __future__ import annotations from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from anta.models import AntaTest, logger if TYPE_CHECKING: from anta.result_manager.models import TestResult -# TODO - should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc +# TODO: should probably use mypy Awaitable in some places rather than this everywhere - @gmuloc F = TypeVar("F", bound=Callable[..., Any]) -def deprecated_test(new_tests: Optional[list[str]] = None) -> Callable[[F], F]: - """ - Return a decorator to log a message of WARNING severity when a test is deprecated. +def deprecated_test(new_tests: list[str] | None = None) -> Callable[[F], F]: + """Return a decorator to log a message of WARNING severity when a test is deprecated. Args: + ---- new_tests (Optional[list[str]]): A list of new test classes that should replace the deprecated test. Returns: + ------- Callable[[F], F]: A decorator that can be used to wrap test functions. + """ def decorator(function: F) -> F: - """ - Actual decorator that logs the message. + """Actual decorator that logs the message. Args: + ---- function (F): The test function to be decorated. Returns: + ------- F: The decorated function. + """ @wraps(function) @@ -54,34 +59,37 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: def skip_on_platforms(platforms: list[str]) -> Callable[[F], F]: - """ - Return a decorator to skip a test based on the device's hardware model. + """Return a decorator to skip a test based on the device's hardware model. This decorator factory generates a decorator that will check the hardware model of the device the test is run on. If the model is in the list of platforms specified, the test will be skipped. Args: + ---- platforms (list[str]): List of hardware models on which the test should be skipped. Returns: + ------- Callable[[F], F]: A decorator that can be used to wrap test functions. + """ def decorator(function: F) -> F: - """ - Actual decorator that either runs the test or skips it based on the device's hardware model. + """Actual decorator that either runs the test or skips it based on the device's hardware model. Args: + ---- function (F): The test function to be decorated. Returns: + ------- F: The decorated function. + """ @wraps(function) async def wrapper(*args: Any, **kwargs: Any) -> TestResult: - """ - Check the device's hardware model and conditionally run or skip the test. + """Check the device's hardware model and conditionally run or skip the test. This wrapper inspects the hardware model of the device the test is run on. If the model is in the list of specified platforms, the test is either skipped. diff --git a/anta/device.py b/anta/device.py index a9ffb35d5..f44fa9791 100644 --- a/anta/device.py +++ b/anta/device.py @@ -1,17 +1,15 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -ANTA Device Abstraction Module -""" +"""ANTA Device Abstraction Module.""" + from __future__ import annotations import asyncio import logging from abc import ABC, abstractmethod from collections import defaultdict -from pathlib import Path -from typing import Any, Iterator, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Iterator, Literal import asyncssh from aiocache import Cache @@ -21,18 +19,23 @@ from anta import __DEBUG__, aioeapi from anta.logger import exc_to_str -from anta.models import AntaCommand + +if TYPE_CHECKING: + from pathlib import Path + + from anta.models import AntaCommand logger = logging.getLogger(__name__) class AntaDevice(ABC): - """ - Abstract class representing a device in ANTA. + """Abstract class representing a device in ANTA. + An implementation of this class must override the abstract coroutines `_collect()` and `refresh()`. - Attributes: + Attributes + ---------- name: Device name is_online: True if the device IP is reachable and a port can be open established: True if remote command execution succeeds @@ -40,26 +43,28 @@ class AntaDevice(ABC): tags: List of tags for this device cache: In-memory cache from aiocache library for this device (None if cache is disabled) cache_locks: Dictionary mapping keys to asyncio locks to guarantee exclusive access to the cache if not disabled + """ - def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: bool = False) -> None: - """ - Constructor of AntaDevice + def __init__(self, name: str, tags: list[str] | None = None, *, disable_cache: bool = False) -> None: + """Initialize an AntaDevice. Args: + ---- name: Device name tags: List of tags for this device disable_cache: Disable caching for all commands for this device. Defaults to False. + """ self.name: str = name - self.hw_model: Optional[str] = None + self.hw_model: str | None = None self.tags: list[str] = tags if tags is not None else [] # A device always has its own name as tag self.tags.append(self.name) self.is_online: bool = False self.established: bool = False - self.cache: Optional[Cache] = None - self.cache_locks: Optional[defaultdict[str, asyncio.Lock]] = None + self.cache: Cache | None = None + self.cache_locks: defaultdict[str, asyncio.Lock] | None = None # Initialize cache if not disabled if not disable_cache: @@ -68,34 +73,24 @@ def __init__(self, name: str, tags: Optional[list[str]] = None, disable_cache: b @property @abstractmethod def _keys(self) -> tuple[Any, ...]: - """ - Read-only property to implement hashing and equality for AntaDevice classes. - """ + """Read-only property to implement hashing and equality for AntaDevice classes.""" def __eq__(self, other: object) -> bool: - """ - Implement equality for AntaDevice objects. - """ + """Implement equality for AntaDevice objects.""" return self._keys == other._keys if isinstance(other, self.__class__) else False def __hash__(self) -> int: - """ - Implement hashing for AntaDevice objects. - """ + """Implement hashing for AntaDevice objects.""" return hash(self._keys) def _init_cache(self) -> None: - """ - Initialize cache for the device, can be overriden by subclasses to manipulate how it works - """ + """Initialize cache for the device, can be overriden by subclasses to manipulate how it works.""" self.cache = Cache(cache_class=Cache.MEMORY, ttl=60, namespace=self.name, plugins=[HitMissRatioPlugin()]) self.cache_locks = defaultdict(asyncio.Lock) @property def cache_statistics(self) -> dict[str, Any] | None: - """ - Returns the device cache statistics for logging purposes - """ + """Returns the device cache statistics for logging purposes.""" # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # https://github.com/pylint-dev/pylint/issues/7258 if self.cache is not None: @@ -104,9 +99,9 @@ def cache_statistics(self) -> dict[str, Any] | None: return None def __rich_repr__(self) -> Iterator[tuple[str, Any]]: - """ - Implements Rich Repr Protocol - https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol + """Implement Rich Repr Protocol. + + https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol. """ yield "name", self.name yield "tags", self.tags @@ -117,8 +112,8 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: @abstractmethod async def _collect(self, command: AntaCommand) -> None: - """ - Collect device command output. + """Collect device command output. + This abstract coroutine can be used to implement any command collection method for a device in ANTA. @@ -130,12 +125,13 @@ async def _collect(self, command: AntaCommand) -> None: `AntaCommand` object passed as argument would be `None` in this case. Args: + ---- command: the command to collect + """ async def collect(self, command: AntaCommand) -> None: - """ - Collects the output for a specified command. + """Collect the output for a specified command. When caching is activated on both the device and the command, this method prioritizes retrieving the output from the cache. In cases where the output isn't cached yet, @@ -146,7 +142,9 @@ async def collect(self, command: AntaCommand) -> None: via the private `_collect` method without interacting with the cache. Args: + ---- command (AntaCommand): The command to process. + """ # Need to ignore pylint no-member as Cache is a proxy class and pylint is not smart enough # https://github.com/pylint-dev/pylint/issues/7258 @@ -155,7 +153,7 @@ async def collect(self, command: AntaCommand) -> None: cached_output = await self.cache.get(command.uid) # pylint: disable=no-member if cached_output is not None: - logger.debug(f"Cache hit for {command.command} on {self.name}") + logger.debug("Cache hit for %s on %s", command.command, self.name) command.output = cached_output else: await self._collect(command=command) @@ -164,26 +162,26 @@ async def collect(self, command: AntaCommand) -> None: await self._collect(command=command) async def collect_commands(self, commands: list[AntaCommand]) -> None: - """ - Collect multiple commands. + """Collect multiple commands. Args: + ---- commands: the commands to collect + """ await asyncio.gather(*(self.collect(command=command) for command in commands)) def supports(self, command: AntaCommand) -> bool: - """Returns True if the command is supported on the device hardware platform, False otherwise.""" + """Return True if the command is supported on the device hardware platform, False otherwise.""" unsupported = any("not supported on this hardware platform" in e for e in command.errors) logger.debug(command) if unsupported: - logger.debug(f"{command.command} is not supported on {self.hw_model}") + logger.debug("%s is not supported on %s", command.command, self.hw_model) return not unsupported @abstractmethod async def refresh(self) -> None: - """ - Update attributes of an AntaDevice instance. + """Update attributes of an AntaDevice instance. This coroutine must update the following attributes of AntaDevice: - `is_online`: When the device IP is reachable and a port can be open @@ -192,50 +190,57 @@ async def refresh(self) -> None: """ async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: - """ - Copy files to and from the device, usually through SCP. + """Copy files to and from the device, usually through SCP. + It is not mandatory to implement this for a valid AntaDevice subclass. Args: + ---- sources: List of files to copy to or from the device. destination: Local or remote destination when copying the files. Can be a folder. direction: Defines if this coroutine copies files to or from the device. + """ - raise NotImplementedError(f"copy() method has not been implemented in {self.__class__.__name__} definition") + _ = (sources, destination, direction) + msg = f"copy() method has not been implemented in {self.__class__.__name__} definition" + raise NotImplementedError(msg) class AsyncEOSDevice(AntaDevice): - """ - Implementation of AntaDevice for EOS using aio-eapi. + """Implementation of AntaDevice for EOS using aio-eapi. - Attributes: + Attributes + ---------- name: Device name is_online: True if the device IP is reachable and a port can be open established: True if remote command execution succeeds hw_model: Hardware model of the device tags: List of tags for this device + """ - def __init__( # pylint: disable=R0913 + # pylint: disable=R0913 + def __init__( self, host: str, username: str, password: str, - name: Optional[str] = None, + name: str | None = None, + enable_password: str | None = None, + port: int | None = None, + ssh_port: int | None = 22, + tags: list[str] | None = None, + timeout: float | None = None, + proto: Literal["http", "https"] = "https", + *, enable: bool = False, - enable_password: Optional[str] = None, - port: Optional[int] = None, - ssh_port: Optional[int] = 22, - tags: Optional[list[str]] = None, - timeout: Optional[float] = None, insecure: bool = False, - proto: Literal["http", "https"] = "https", disable_cache: bool = False, ) -> None: - """ - Constructor of AsyncEOSDevice + """Instantiate an AsyncEOSDevice. Args: + ---- host: Device FQDN or IP username: Username to connect to eAPI and SSH password: Password to connect to eAPI and SSH @@ -249,6 +254,7 @@ def __init__( # pylint: disable=R0913 insecure: Disable SSH Host Key validation proto: eAPI protocol. Value can be 'http' or 'https' disable_cache: Disable caching for all commands for this device. Defaults to False. + """ if host is None: message = "'host' is required to create an AsyncEOSDevice" @@ -256,7 +262,7 @@ def __init__( # pylint: disable=R0913 raise ValueError(message) if name is None: name = f"{host}{f':{port}' if port else ''}" - super().__init__(name, tags, disable_cache) + super().__init__(name, tags, disable_cache=disable_cache) if username is None: message = f"'username' is required to instantiate device '{self.name}'" logger.error(message) @@ -274,9 +280,9 @@ def __init__( # pylint: disable=R0913 self._ssh_opts: SSHClientConnectionOptions = SSHClientConnectionOptions(host=host, port=ssh_port, username=username, password=password, **ssh_params) def __rich_repr__(self) -> Iterator[tuple[str, Any]]: - """ - Implements Rich Repr Protocol - https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol + """Implement Rich Repr Protocol. + + https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol. """ yield from super().__rich_repr__() yield ("host", self._session.host) @@ -286,38 +292,39 @@ def __rich_repr__(self) -> Iterator[tuple[str, Any]]: yield ("insecure", self._ssh_opts.known_hosts is None) if __DEBUG__: _ssh_opts = vars(self._ssh_opts).copy() - PASSWORD_VALUE = "" - _ssh_opts["password"] = PASSWORD_VALUE - _ssh_opts["kwargs"]["password"] = PASSWORD_VALUE + removed_pw = "" + _ssh_opts["password"] = removed_pw + _ssh_opts["kwargs"]["password"] = removed_pw yield ("_session", vars(self._session)) yield ("_ssh_opts", _ssh_opts) @property def _keys(self) -> tuple[Any, ...]: - """ - Two AsyncEOSDevice objects are equal if the hostname and the port are the same. + """Two AsyncEOSDevice objects are equal if the hostname and the port are the same. + This covers the use case of port forwarding when the host is localhost and the devices have different ports. """ return (self._session.host, self._session.port) async def _collect(self, command: AntaCommand) -> None: - """ - Collect device command output from EOS using aio-eapi. + """Collect device command output from EOS using aio-eapi. Supports outformat `json` and `text` as output structure. Gain privileged access using the `enable_password` attribute of the `AntaDevice` instance if populated. Args: + ---- command: the command to collect + """ - commands = [] + commands: list[dict[str, Any]] = [] if self.enable and self._enable_password is not None: commands.append( { "cmd": "enable", "input": str(self._enable_password), - } + }, ) elif self.enable: # No password @@ -335,58 +342,56 @@ async def _collect(self, command: AntaCommand) -> None: except aioeapi.EapiCommandError as e: command.errors = e.errors if self.supports(command): - message = f"Command '{command.command}' failed on {self.name}" - logger.error(message) + logger.error("Command '%s' failed on %s", command.command, self.name) except (HTTPError, ConnectError) as e: command.errors = [str(e)] - message = f"Cannot connect to device {self.name}" - logger.error(message) + logger.error("Cannot connect to device %s", self.name) else: # selecting only our command output command.output = response[-1] - logger.debug(f"{self.name}: {command}") + logger.debug("%s: %s", self.name, command) async def refresh(self) -> None: - """ - Update attributes of an AsyncEOSDevice instance. + """Update attributes of an AsyncEOSDevice instance. This coroutine must update the following attributes of AsyncEOSDevice: - is_online: When a device IP is reachable and a port can be open - established: When a command execution succeeds - hw_model: The hardware model of the device """ - logger.debug(f"Refreshing device {self.name}") + logger.debug("Refreshing device %s", self.name) self.is_online = await self._session.check_connection() if self.is_online: - COMMAND: str = "show version" - HW_MODEL_KEY: str = "modelName" + show_version = "show version" + hw_model_key = "modelName" try: - response = await self._session.cli(command=COMMAND) + response = await self._session.cli(command=show_version) except aioeapi.EapiCommandError as e: - logger.warning(f"Cannot get hardware information from device {self.name}: {e.errmsg}") + logger.warning("Cannot get hardware information from device %s: %s", self.name, e.errmsg) except (HTTPError, ConnectError) as e: - logger.warning(f"Cannot get hardware information from device {self.name}: {exc_to_str(e)}") + logger.warning("Cannot get hardware information from device %s: %s", self.name, exc_to_str(e)) else: - if HW_MODEL_KEY in response: - self.hw_model = response[HW_MODEL_KEY] + if hw_model_key in response: + self.hw_model = response[hw_model_key] else: - logger.warning(f"Cannot get hardware information from device {self.name}: cannot parse '{COMMAND}'") + logger.warning("Cannot get hardware information from device %s: cannot parse '%s'", self.name, show_version) else: - logger.warning(f"Could not connect to device {self.name}: cannot open eAPI port") + logger.warning("Could not connect to device %s: cannot open eAPI port", self.name) self.established = bool(self.is_online and self.hw_model) async def copy(self, sources: list[Path], destination: Path, direction: Literal["to", "from"] = "from") -> None: - """ - Copy files to and from the device using asyncssh.scp(). + """Copy files to and from the device using asyncssh.scp(). Args: + ---- sources: List of files to copy to or from the device. destination: Local or remote destination when copying the files. Can be a folder. direction: Defines if this coroutine copies files to or from the device. + """ async with asyncssh.connect( host=self._ssh_opts.host, @@ -396,22 +401,24 @@ async def copy(self, sources: list[Path], destination: Path, direction: Literal[ local_addr=self._ssh_opts.local_addr, options=self._ssh_opts, ) as conn: - src: Union[list[tuple[SSHClientConnection, Path]], list[Path]] - dst: Union[tuple[SSHClientConnection, Path], Path] + src: list[tuple[SSHClientConnection, Path]] | list[Path] + dst: tuple[SSHClientConnection, Path] | Path if direction == "from": src = [(conn, file) for file in sources] dst = destination for file in sources: - logger.info(f"Copying '{file}' from device {self.name} to '{destination}' locally") + message = f"Copying '{file}' from device {self.name} to '{destination}' locally" + logger.info(message) elif direction == "to": src = sources dst = conn, destination for file in src: - logger.info(f"Copying '{file}' to device {self.name} to '{destination}' remotely") + message = f"Copying '{file}' to device {self.name} to '{destination}' remotely" + logger.info(message) else: - logger.critical(f"'direction' argument to copy() fonction is invalid: {direction}") + logger.critical("'direction' argument to copy() fonction is invalid: %s", direction) return await asyncssh.scp(src, dst) diff --git a/anta/inventory/__init__.py b/anta/inventory/__init__.py index c5327bd44..6ba423653 100644 --- a/anta/inventory/__init__.py +++ b/anta/inventory/__init__.py @@ -1,9 +1,7 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Inventory Module for ANTA. -""" +"""Inventory module for ANTA.""" from __future__ import annotations @@ -11,32 +9,30 @@ import logging from ipaddress import ip_address, ip_network from pathlib import Path -from typing import Any, Optional +from typing import Any, ClassVar, Dict from pydantic import ValidationError from yaml import YAMLError, safe_load from anta.device import AntaDevice, AsyncEOSDevice -from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError +from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError from anta.inventory.models import AntaInventoryInput from anta.logger import anta_log_exception logger = logging.getLogger(__name__) -class AntaInventory(dict): # type: ignore - # dict[str, AntaDevice] - not working in python 3.8 hence the ignore - """ - Inventory abstraction for ANTA framework. - """ +# TODO: Replace to `dict[str, AntaDevice]` when we drop Python 3.8 +class AntaInventory(Dict[str, AntaDevice]): + """Inventory abstraction for ANTA framework.""" # Root key of inventory part of the inventory file INVENTORY_ROOT_KEY = "anta_inventory" # Supported Output format - INVENTORY_OUTPUT_FORMAT = ["native", "json"] + INVENTORY_OUTPUT_FORMAT: ClassVar[list[str]] = ["native", "json"] def __str__(self) -> str: - """Human readable string representing the inventory""" + """Human readable string representing the inventory.""" devs = {} for dev in self.values(): if (dev_type := dev.__class__.__name__) not in devs: @@ -46,80 +42,106 @@ def __str__(self) -> str: return f"ANTA Inventory contains {' '.join([f'{n} devices ({t})' for t, n in devs.items()])}" @staticmethod - def _update_disable_cache(inventory_disable_cache: bool, kwargs: dict[str, Any]) -> dict[str, Any]: - """ - Return new dictionary, replacing kwargs with added disable_cache value from inventory_value - if disable_cache has not been set by CLI. + def _update_disable_cache(kwargs: dict[str, Any], *, inventory_disable_cache: bool) -> dict[str, Any]: + """Return new dictionary, replacing kwargs with added disable_cache value from inventory_value if disable_cache has not been set by CLI. Args: + ---- inventory_disable_cache (bool): The value of disable_cache in the inventory kwargs: The kwargs to instantiate the device + """ updated_kwargs = kwargs.copy() updated_kwargs["disable_cache"] = inventory_disable_cache or kwargs.get("disable_cache") return updated_kwargs @staticmethod - def _parse_hosts(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None: - """ - Parses the host section of an AntaInventoryInput and add the devices to the inventory + def _parse_hosts( + inventory_input: AntaInventoryInput, + inventory: AntaInventory, + **kwargs: dict[str, Any], + ) -> None: + """Parse the host section of an AntaInventoryInput and add the devices to the inventory. Args: + ---- inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices inventory (AntaInventory): AntaInventory to add the parsed devices to + **kwargs (dict[str, Any]): Additional keywork arguments to pass to the device constructor + """ if inventory_input.hosts is None: return for host in inventory_input.hosts: - updated_kwargs = AntaInventory._update_disable_cache(host.disable_cache, kwargs) - device = AsyncEOSDevice(name=host.name, host=str(host.host), port=host.port, tags=host.tags, **updated_kwargs) + updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=host.disable_cache) + device = AsyncEOSDevice( + name=host.name, + host=str(host.host), + port=host.port, + tags=host.tags, + **updated_kwargs, + ) inventory.add_device(device) @staticmethod - def _parse_networks(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None: - """ - Parses the network section of an AntaInventoryInput and add the devices to the inventory. + def _parse_networks( + inventory_input: AntaInventoryInput, + inventory: AntaInventory, + **kwargs: dict[str, Any], + ) -> None: + """Parse the network section of an AntaInventoryInput and add the devices to the inventory. Args: + ---- inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices inventory (AntaInventory): AntaInventory to add the parsed devices to + **kwargs (dict[str, Any]): Additional keywork arguments to pass to the device constructor Raises: - InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema. + ------ + InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + """ if inventory_input.networks is None: return - for network in inventory_input.networks: - try: - updated_kwargs = AntaInventory._update_disable_cache(network.disable_cache, kwargs) + try: + for network in inventory_input.networks: + updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=network.disable_cache) for host_ip in ip_network(str(network.network)): device = AsyncEOSDevice(host=str(host_ip), tags=network.tags, **updated_kwargs) inventory.add_device(device) - except ValueError as e: - message = "Could not parse network {network.network} in the inventory" - anta_log_exception(e, message, logger) - raise InventoryIncorrectSchema(message) from e + except ValueError as e: + message = "Could not parse the network section in the inventory" + anta_log_exception(e, message, logger) + raise InventoryIncorrectSchemaError(message) from e @staticmethod - def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, **kwargs: Any) -> None: - """ - Parses the range section of an AntaInventoryInput and add the devices to the inventory. + def _parse_ranges( + inventory_input: AntaInventoryInput, + inventory: AntaInventory, + **kwargs: dict[str, Any], + ) -> None: + """Parse the range section of an AntaInventoryInput and add the devices to the inventory. Args: + ---- inventory_input (AntaInventoryInput): AntaInventoryInput used to parse the devices inventory (AntaInventory): AntaInventory to add the parsed devices to + **kwargs (dict[str, Any]): Additional keywork arguments to pass to the device constructor Raises: - InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema. + ------ + InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + """ if inventory_input.ranges is None: return - for range_def in inventory_input.ranges: - try: - updated_kwargs = AntaInventory._update_disable_cache(range_def.disable_cache, kwargs) + try: + for range_def in inventory_input.ranges: + updated_kwargs = AntaInventory._update_disable_cache(kwargs, inventory_disable_cache=range_def.disable_cache) range_increment = ip_address(str(range_def.start)) range_stop = ip_address(str(range_def.end)) while range_increment <= range_stop: # type: ignore[operator] @@ -128,32 +150,34 @@ def _parse_ranges(inventory_input: AntaInventoryInput, inventory: AntaInventory, device = AsyncEOSDevice(host=str(range_increment), tags=range_def.tags, **updated_kwargs) inventory.add_device(device) range_increment += 1 - except ValueError as e: - message = f"Could not parse the following range in the inventory: {range_def.start} - {range_def.end}" - anta_log_exception(e, message, logger) - raise InventoryIncorrectSchema(message) from e - except TypeError as e: - message = f"A range in the inventory has different address families for start and end: {range_def.start} - {range_def.end}" - anta_log_exception(e, message, logger) - raise InventoryIncorrectSchema(message) from e + except ValueError as e: + message = "Could not parse the range section in the inventory" + anta_log_exception(e, message, logger) + raise InventoryIncorrectSchemaError(message) from e + except TypeError as e: + message = "A range in the inventory has different address families (IPv4 vs IPv6)" + anta_log_exception(e, message, logger) + raise InventoryIncorrectSchemaError(message) from e + # pylint: disable=too-many-arguments @staticmethod def parse( filename: str | Path, username: str, password: str, + enable_password: str | None = None, + timeout: float | None = None, + *, enable: bool = False, - enable_password: Optional[str] = None, - timeout: Optional[float] = None, insecure: bool = False, disable_cache: bool = False, ) -> AntaInventory: - # pylint: disable=too-many-arguments - """ - Create an AntaInventory instance from an inventory file. + """Create an AntaInventory instance from an inventory file. + The inventory devices are AsyncEOSDevice instances. Args: + ---- filename (str): Path to device inventory YAML file username (str): Username to use to connect to devices password (str): Password to use to connect to devices @@ -164,10 +188,11 @@ def parse( disable_cache (bool): Disable cache globally Raises: + ------ InventoryRootKeyError: Root key of inventory is missing. - InventoryIncorrectSchema: Inventory file is not following AntaInventory Schema. - """ + InventoryIncorrectSchemaError: Inventory file is not following AntaInventory Schema. + """ inventory = AntaInventory() kwargs: dict[str, Any] = { "username": username, @@ -188,7 +213,8 @@ def parse( raise ValueError(message) try: - with open(file=filename, mode="r", encoding="UTF-8") as file: + filename = Path(filename) + with filename.open(encoding="UTF-8") as file: data = safe_load(file) except (TypeError, YAMLError, OSError) as e: message = f"Unable to parse ANTA Device Inventory file '{filename}'" @@ -221,23 +247,22 @@ def parse( # GET methods ########################################################################### - def get_inventory(self, established_only: bool = False, tags: Optional[list[str]] = None) -> AntaInventory: - """ - Returns a filtered inventory. + def get_inventory(self, *, established_only: bool = False, tags: list[str] | None = None) -> AntaInventory: + """Return a filtered inventory. Args: + ---- established_only: Whether or not to include only established devices. Default False. tags: List of tags to filter devices. Returns: + ------- AntaInventory: An inventory with filtered AntaDevice objects. + """ def _filter_devices(device: AntaDevice) -> bool: - """ - Helper function to select the devices based on the input tags - and the requirement for an established connection. - """ + """Select the devices based on the input tags and the requirement for an established connection.""" if tags is not None and all(tag not in tags for tag in device.tags): return False return bool(not established_only or device.established) @@ -253,15 +278,19 @@ def _filter_devices(device: AntaDevice) -> bool: ########################################################################### def __setitem__(self, key: str, value: AntaDevice) -> None: + """Set a device in the inventory.""" if key != value.name: - raise RuntimeError(f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device().") + msg = f"The key must be the device name for device '{value.name}'. Use AntaInventory.add_device()." + raise RuntimeError(msg) return super().__setitem__(key, value) def add_device(self, device: AntaDevice) -> None: """Add a device to final inventory. Args: + ---- device: Device object to be added + """ self[device.name] = device diff --git a/anta/inventory/exceptions.py b/anta/inventory/exceptions.py index dd5f106fe..90a672f61 100644 --- a/anta/inventory/exceptions.py +++ b/anta/inventory/exceptions.py @@ -8,5 +8,5 @@ class InventoryRootKeyError(Exception): """Error raised when inventory root key is not found.""" -class InventoryIncorrectSchema(Exception): +class InventoryIncorrectSchemaError(Exception): """Error when user data does not follow ANTA schema.""" diff --git a/anta/inventory/models.py b/anta/inventory/models.py index 94742e45a..b131eb7a4 100644 --- a/anta/inventory/models.py +++ b/anta/inventory/models.py @@ -6,87 +6,87 @@ from __future__ import annotations import logging -from typing import List, Optional, Union -# Need to keep List for pydantic in python 3.8 -from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork, conint, constr +from pydantic import BaseModel, ConfigDict, IPvAnyAddress, IPvAnyNetwork -logger = logging.getLogger(__name__) - -# Pydantic models for input validation +from anta.custom_types import Hostname, Port -RFC_1123_REGEX = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" +logger = logging.getLogger(__name__) class AntaInventoryHost(BaseModel): - """ - Host definition for user's inventory. + """Host definition for user's inventory. - Attributes: + Attributes + ---------- host (IPvAnyAddress): IPv4 or IPv6 address of the device port (int): (Optional) eAPI port to use Default is 443. name (str): (Optional) Name to display during tests report. Default is hostname:port tags (list[str]): List of attached tags read from inventory file. disable_cache (bool): Disable cache per host. Defaults to False. + """ model_config = ConfigDict(extra="forbid") - name: Optional[str] = None - host: Union[constr(pattern=RFC_1123_REGEX), IPvAnyAddress] # type: ignore - port: Optional[conint(gt=1, lt=65535)] = None # type: ignore - tags: Optional[List[str]] = None + name: str | None = None + host: Hostname | IPvAnyAddress + port: Port | None = None + tags: list[str] | None = None disable_cache: bool = False class AntaInventoryNetwork(BaseModel): - """ - Network definition for user's inventory. + """Network definition for user's inventory. - Attributes: + Attributes + ---------- network (IPvAnyNetwork): Subnet to use for testing. tags (list[str]): List of attached tags read from inventory file. disable_cache (bool): Disable cache per network. Defaults to False. + """ model_config = ConfigDict(extra="forbid") network: IPvAnyNetwork - tags: Optional[List[str]] = None + tags: list[str] | None = None disable_cache: bool = False class AntaInventoryRange(BaseModel): - """ - IP Range definition for user's inventory. + """IP Range definition for user's inventory. - Attributes: + Attributes + ---------- start (IPvAnyAddress): IPv4 or IPv6 address for the begining of the range. stop (IPvAnyAddress): IPv4 or IPv6 address for the end of the range. tags (list[str]): List of attached tags read from inventory file. disable_cache (bool): Disable cache per range of hosts. Defaults to False. + """ model_config = ConfigDict(extra="forbid") start: IPvAnyAddress end: IPvAnyAddress - tags: Optional[List[str]] = None + tags: list[str] | None = None disable_cache: bool = False class AntaInventoryInput(BaseModel): - """ - User's inventory model. + """User's inventory model. - Attributes: + Attributes + ---------- networks (list[AntaInventoryNetwork],Optional): List of AntaInventoryNetwork objects for networks. hosts (list[AntaInventoryHost],Optional): List of AntaInventoryHost objects for hosts. range (list[AntaInventoryRange],Optional): List of AntaInventoryRange objects for ranges. + """ model_config = ConfigDict(extra="forbid") - networks: Optional[List[AntaInventoryNetwork]] = None - hosts: Optional[List[AntaInventoryHost]] = None - ranges: Optional[List[AntaInventoryRange]] = None + networks: list[AntaInventoryNetwork] | None = None + hosts: list[AntaInventoryHost] | None = None + ranges: list[AntaInventoryRange] | None = None diff --git a/anta/logger.py b/anta/logger.py index df6b4ea8a..1b0a586a0 100644 --- a/anta/logger.py +++ b/anta/logger.py @@ -1,26 +1,27 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Configure logging for ANTA -""" +"""Configure logging for ANTA.""" + from __future__ import annotations import logging import traceback from enum import Enum -from pathlib import Path -from typing import Literal, Optional +from typing import TYPE_CHECKING, Literal from rich.logging import RichHandler from anta import __DEBUG__ +if TYPE_CHECKING: + from pathlib import Path + logger = logging.getLogger(__name__) class Log(str, Enum): - """Represent log levels from logging module as immutable strings""" + """Represent log levels from logging module as immutable strings.""" CRITICAL = logging.getLevelName(logging.CRITICAL) ERROR = logging.getLevelName(logging.ERROR) @@ -33,8 +34,8 @@ class Log(str, Enum): def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: - """ - Configure logging for ANTA. + """Configure logging for ANTA. + By default, the logging level is INFO for all loggers except for httpx and asyncssh which are too verbose: their logging level is WARNING. @@ -48,8 +49,10 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: be logged to stdout while all levels will be logged in the file. Args: + ---- level: ANTA logging level file: Send logs to a file + """ # Init root logger root = logging.getLogger() @@ -64,58 +67,51 @@ def setup_logging(level: LogLevel = Log.INFO, file: Path | None = None) -> None: logging.getLogger("httpx").setLevel(logging.WARNING) # Add RichHandler for stdout - richHandler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False) + rich_handler = RichHandler(markup=True, rich_tracebacks=True, tracebacks_show_locals=False) # In ANTA debug mode, show Python module in stdout - if __DEBUG__: - fmt_string = r"[grey58]\[%(name)s][/grey58] %(message)s" - else: - fmt_string = "%(message)s" + fmt_string = "[grey58]\\[%(name)s][/grey58] %(message)s" if __DEBUG__ else "%(message)s" formatter = logging.Formatter(fmt=fmt_string, datefmt="[%X]") - richHandler.setFormatter(formatter) - root.addHandler(richHandler) + rich_handler.setFormatter(formatter) + root.addHandler(rich_handler) # Add FileHandler if file is provided if file: - fileHandler = logging.FileHandler(file) + file_handler = logging.FileHandler(file) formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - fileHandler.setFormatter(formatter) - root.addHandler(fileHandler) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) # If level is DEBUG and file is provided, do not send DEBUG level to stdout if loglevel == logging.DEBUG: - richHandler.setLevel(logging.INFO) + rich_handler.setLevel(logging.INFO) if __DEBUG__: logger.debug("ANTA Debug Mode enabled") def exc_to_str(exception: BaseException) -> str: - """ - Helper function that returns a human readable string from an BaseException object - """ + """Return a human readable string from an BaseException object.""" return f"{type(exception).__name__}{f': {exception}' if str(exception) else ''}" -def anta_log_exception(exception: BaseException, message: Optional[str] = None, calling_logger: Optional[logging.Logger] = None) -> None: - """ - Helper function to help log exceptions: - * if anta.__DEBUG__ is True then the logger.exception method is called to get the traceback - * otherwise logger.error is called +def anta_log_exception(exception: BaseException, message: str | None = None, calling_logger: logging.Logger | None = None) -> None: + """Log exception. + + If `anta.__DEBUG__` is True then the `logger.exception` method is called to get the traceback, otherwise `logger.error` is called. Args: + ---- exception (BaseException): The Exception being logged message (str): An optional message - calling_logger (logging.Logger): A logger to which the exception should be logged - if not present, the logger in this file is used. + calling_logger (logging.Logger): A logger to which the exception should be logged. If not present, the logger in this file is used. """ if calling_logger is None: calling_logger = logger calling_logger.critical(f"{message}\n{exc_to_str(exception)}" if message else exc_to_str(exception)) if __DEBUG__: - calling_logger.exception(f"[ANTA Debug Mode]{f' {message}' if message else ''}", exc_info=exception) + msg = f"[ANTA Debug Mode]{f' {message}' if message else ''}" + calling_logger.exception(msg, exc_info=exception) def tb_to_str(exception: BaseException) -> str: - """ - Helper function that returns a traceback string from an BaseException object - """ + """Return a traceback string from an BaseException object.""" return "Traceback (most recent call last):\n" + "".join(traceback.format_tb(exception.__traceback__)) diff --git a/anta/models.py b/anta/models.py index 5f32c69c4..32ed6a5ee 100644 --- a/anta/models.py +++ b/anta/models.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Models to define a TestStructure -""" +"""Models to define a TestStructure.""" + from __future__ import annotations import hashlib @@ -14,75 +13,79 @@ from copy import deepcopy from datetime import timedelta from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Literal, TypeVar -# Need to keep Dict and List for pydantic in python 3.8 -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Literal, Optional, TypeVar, Union - -from pydantic import BaseModel, ConfigDict, ValidationError, conint -from rich.progress import Progress, TaskID +from pydantic import BaseModel, ConfigDict, ValidationError from anta import GITHUB_SUGGESTION +from anta.custom_types import Revision from anta.logger import anta_log_exception, exc_to_str from anta.result_manager.models import TestResult if TYPE_CHECKING: + from rich.progress import Progress, TaskID + from anta.device import AntaDevice F = TypeVar("F", bound=Callable[..., Any]) # Proper way to type input class - revisit this later if we get any issue @gmuloc # This would imply overhead to define classes # https://stackoverflow.com/questions/74103528/type-hinting-an-instance-of-a-nested-class -# N = TypeVar("N", bound="AntaTest.Input") - -# TODO - make this configurable - with an env var maybe? +# TODO: make this configurable - with an env var maybe? BLACKLIST_REGEX = [r"^reload.*", r"^conf\w*\s*(terminal|session)*", r"^wr\w*\s*\w+"] logger = logging.getLogger(__name__) -class AntaMissingParamException(Exception): - """ - This Exception should be used when an expected key in an AntaCommand.params dictionary - was not found. +class AntaMissingParamError(Exception): + """An expected key in an AntaCommand.params dictionary was not found. This Exception should in general never be raised in normal usage of ANTA. """ def __init__(self, message: str) -> None: - self.message = "\n".join([message, GITHUB_SUGGESTION]) + """Append Github suggestion to message.""" + self.message = f"{message}\n{GITHUB_SUGGESTION}" super().__init__(self.message) class AntaTemplate(BaseModel): """Class to define a command template as Python f-string. + Can render a command from parameters. - Attributes: + Attributes + ---------- template: Python f-string. Example: 'show vlan {vlan_id}' version: eAPI version - valid values are 1 or "latest" - default is "latest" revision: Revision of the command. Valid values are 1 to 99. Revision has precedence over version. ofmt: eAPI output - json or text - default is json use_cache: Enable or disable caching for this AntaTemplate if the AntaDevice supports it - default is True + """ template: str version: Literal[1, "latest"] = "latest" - revision: Optional[conint(ge=1, le=99)] = None # type: ignore + revision: Revision | None = None ofmt: Literal["json", "text"] = "json" use_cache: bool = True def render(self, **params: dict[str, Any]) -> AntaCommand: """Render an AntaCommand from an AntaTemplate instance. + Keep the parameters used in the AntaTemplate instance. Args: + ---- params: dictionary of variables with string values to render the Python f-string Returns: + ------- command: The rendered AntaCommand. This AntaCommand instance have a template attribute that references this AntaTemplate instance. + """ try: return AntaCommand( @@ -112,7 +115,8 @@ class AntaCommand(BaseModel): __Revision has precedence over version.__ - Attributes: + Attributes + ---------- command: Device command version: eAPI version - valid values are 1 or "latest" - default is "latest" revision: eAPI revision of the command. Valid values are 1 to 99. Revision has precedence over version. @@ -122,60 +126,65 @@ class AntaCommand(BaseModel): params: Dictionary of variables with string values to render the template errors: If the command execution fails, eAPI returns a list of strings detailing the error use_cache: Enable or disable caching for this AntaCommand if the AntaDevice supports it - default is True + """ command: str version: Literal[1, "latest"] = "latest" - revision: Optional[conint(ge=1, le=99)] = None # type: ignore + revision: Revision | None = None ofmt: Literal["json", "text"] = "json" - output: Optional[Union[Dict[str, Any], str]] = None - template: Optional[AntaTemplate] = None - errors: List[str] = [] - params: Dict[str, Any] = {} + output: dict[str, Any] | str | None = None + template: AntaTemplate | None = None + errors: list[str] = [] + params: dict[str, Any] = {} use_cache: bool = True @property def uid(self) -> str: - """Generate a unique identifier for this command""" + """Generate a unique identifier for this command.""" uid_str = f"{self.command}_{self.version}_{self.revision or 'NA'}_{self.ofmt}" - return hashlib.sha1(uid_str.encode()).hexdigest() + # Ignoring S324 probable use of insecure hash function - sha1 is enough for our needs. + return hashlib.sha1(uid_str.encode()).hexdigest() # noqa: S324 @property def json_output(self) -> dict[str, Any]: - """Get the command output as JSON""" + """Get the command output as JSON.""" if self.output is None: - raise RuntimeError(f"There is no output for command {self.command}") + msg = f"There is no output for command {self.command}" + raise RuntimeError(msg) if self.ofmt != "json" or not isinstance(self.output, dict): - raise RuntimeError(f"Output of command {self.command} is invalid") + msg = f"Output of command {self.command} is invalid" + raise RuntimeError(msg) return dict(self.output) @property def text_output(self) -> str: - """Get the command output as a string""" + """Get the command output as a string.""" if self.output is None: - raise RuntimeError(f"There is no output for command {self.command}") + msg = f"There is no output for command {self.command}" + raise RuntimeError(msg) if self.ofmt != "text" or not isinstance(self.output, str): - raise RuntimeError(f"Output of command {self.command} is invalid") + msg = f"Output of command {self.command} is invalid" + raise RuntimeError(msg) return str(self.output) @property def collected(self) -> bool: - """Return True if the command has been collected""" + """Return True if the command has been collected.""" return self.output is not None and not self.errors class AntaTemplateRenderError(RuntimeError): - """ - Raised when an AntaTemplate object could not be rendered - because of missing parameters - """ + """Raised when an AntaTemplate object could not be rendered because of missing parameters.""" - def __init__(self, template: AntaTemplate, key: str): - """Constructor for AntaTemplateRenderError + def __init__(self, template: AntaTemplate, key: str) -> None: + """Initialize an AntaTemplateRenderError. Args: + ---- template: The AntaTemplate instance that failed to render key: Key that has not been provided to render the template + """ self.template = template self.key = key @@ -183,12 +192,13 @@ def __init__(self, template: AntaTemplate, key: str): class AntaTest(ABC): - """Abstract class defining a test in ANTA + """Abstract class defining a test in ANTA. The goal of this class is to handle the heavy lifting and make writing a test as simple as possible. - Examples: + Examples + -------- The following is an example of an AntaTest subclass implementation: ```python class VerifyReachability(AntaTest): @@ -226,22 +236,24 @@ def test(self) -> None: instance_commands: List of AntaCommand instances of this test result: TestResult instance representing the result of this test logger: Python logger for this test instance + """ # Mandatory class attributes - # TODO - find a way to tell mypy these are mandatory for child classes - maybe Protocol + # TODO: find a way to tell mypy these are mandatory for child classes - maybe Protocol name: ClassVar[str] description: ClassVar[str] categories: ClassVar[list[str]] - commands: ClassVar[list[Union[AntaTemplate, AntaCommand]]] + commands: ClassVar[list[AntaTemplate | AntaCommand]] # Class attributes to handle the progress bar of ANTA CLI - progress: Optional[Progress] = None - nrfu_task: Optional[TaskID] = None + progress: Progress | None = None + nrfu_task: TaskID | None = None class Input(BaseModel): """Class defining inputs for a test in ANTA. - Examples: + Examples + -------- A valid test catalog will look like the following: ```yaml : @@ -254,72 +266,85 @@ class Input(BaseModel): ``` Attributes: result_overwrite: Define fields to overwrite in the TestResult object + """ model_config = ConfigDict(extra="forbid") - result_overwrite: Optional[ResultOverwrite] = None - filters: Optional[Filters] = None + result_overwrite: ResultOverwrite | None = None + filters: Filters | None = None def __hash__(self) -> int: - """ - Implement generic hashing for AntaTest.Input. + """Implement generic hashing for AntaTest.Input. + This will work in most cases but this does not consider 2 lists with different ordering as equal. """ return hash(self.model_dump_json()) class ResultOverwrite(BaseModel): - """Test inputs model to overwrite result fields + """Test inputs model to overwrite result fields. - Attributes: + Attributes + ---------- description: overwrite TestResult.description categories: overwrite TestResult.categories custom_field: a free string that will be included in the TestResult object + """ model_config = ConfigDict(extra="forbid") - description: Optional[str] = None - categories: Optional[List[str]] = None - custom_field: Optional[str] = None + description: str | None = None + categories: list[str] | None = None + custom_field: str | None = None class Filters(BaseModel): - """Runtime filters to map tests with list of tags or devices + """Runtime filters to map tests with list of tags or devices. - Attributes: + Attributes + ---------- tags: List of device's tags for the test. + """ model_config = ConfigDict(extra="forbid") - tags: Optional[List[str]] = None + tags: list[str] | None = None def __init__( self, device: AntaDevice, inputs: dict[str, Any] | AntaTest.Input | None = None, eos_data: list[dict[Any, Any] | str] | None = None, - ): - """AntaTest Constructor + ) -> None: + """AntaTest Constructor. Args: + ---- device: AntaDevice instance on which the test will be run inputs: dictionary of attributes used to instantiate the AntaTest.Input instance eos_data: Populate outputs of the test commands instead of collecting from devices. This list must have the same length and order than the `instance_commands` instance attribute. + """ self.logger: logging.Logger = logging.getLogger(f"{self.__module__}.{self.__class__.__name__}") self.device: AntaDevice = device self.inputs: AntaTest.Input self.instance_commands: list[AntaCommand] = [] - self.result: TestResult = TestResult(name=device.name, test=self.name, categories=self.categories, description=self.description) + self.result: TestResult = TestResult( + name=device.name, + test=self.name, + categories=self.categories, + description=self.description, + ) self._init_inputs(inputs) if self.result.result == "unset": self._init_commands(eos_data) def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: - """Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance - to validate test inputs from defined model. + """Instantiate the `inputs` instance attribute with an `AntaTest.Input` instance to validate test inputs using the model. + Overwrite result fields based on `ResultOverwrite` input definition. - Any input validation error will set this test result status as 'error'.""" + Any input validation error will set this test result status as 'error'. + """ try: if inputs is None: self.inputs = self.Input() @@ -339,10 +364,11 @@ def _init_inputs(self, inputs: dict[str, Any] | AntaTest.Input | None) -> None: self.result.description = res_ow.description self.result.custom_field = res_ow.custom_field - def _init_commands(self, eos_data: Optional[list[dict[Any, Any] | str]]) -> None: + def _init_commands(self, eos_data: list[dict[Any, Any] | str] | None) -> None: """Instantiate the `instance_commands` instance attribute from the `commands` class attribute. + - Copy of the `AntaCommand` instances - - Render all `AntaTemplate` instances using the `render()` method + - Render all `AntaTemplate` instances using the `render()` method. Any template rendering error will set this test result status as 'error'. Any exception in user code in `render()` will set this test result status as 'error'. @@ -370,11 +396,11 @@ def _init_commands(self, eos_data: Optional[list[dict[Any, Any] | str]]) -> None return if eos_data is not None: - self.logger.debug(f"Test {self.name} initialized with input data") + self.logger.debug("Test %s initialized with input data", self.name) self.save_commands_data(eos_data) def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None: - """Populate output of all AntaCommand instances in `instance_commands`""" + """Populate output of all AntaCommand instances in `instance_commands`.""" if len(eos_data) > len(self.instance_commands): self.result.is_error(message="Test initialization error: Trying to save more data than there are commands for the test") return @@ -385,11 +411,12 @@ def save_commands_data(self, eos_data: list[dict[str, Any] | str]) -> None: self.instance_commands[index].output = data def __init_subclass__(cls) -> None: - """Verify that the mandatory class attributes are defined""" + """Verify that the mandatory class attributes are defined.""" mandatory_attributes = ["name", "description", "categories", "commands"] for attr in mandatory_attributes: if not hasattr(cls, attr): - raise NotImplementedError(f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}") + msg = f"Class {cls.__module__}.{cls.__name__} is missing required class attribute {attr}" + raise NotImplementedError(msg) @property def collected(self) -> bool: @@ -401,13 +428,15 @@ def failed_commands(self) -> list[AntaCommand]: """Returns a list of all the commands that have failed.""" return [command for command in self.instance_commands if command.errors] - def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Render an AntaTemplate instance of this AntaTest using the provided - AntaTest.Input instance at self.inputs. + # Disabling unused argument + def render(self, template: AntaTemplate) -> list[AntaCommand]: # pylint: disable=W0613 # noqa: ARG002 + """Render an AntaTemplate instance of this AntaTest using the provided AntaTest.Input instance at self.inputs. This is not an abstract method because it does not need to be implemented if there is - no AntaTemplate for this test.""" - raise NotImplementedError(f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}") + no AntaTemplate for this test. + """ + msg = f"AntaTemplate are provided but render() method has not been implemented for {self.__module__}.{self.name}" + raise NotImplementedError(msg) @property def blocked(self) -> bool: @@ -416,15 +445,17 @@ def blocked(self) -> bool: for command in self.instance_commands: for pattern in BLACKLIST_REGEX: if re.match(pattern, command.command): - self.logger.error(f"Command <{command.command}> is blocked for security reason matching {BLACKLIST_REGEX}") + self.logger.error( + "Command <%s> is blocked for security reason matching %s", + command.command, + BLACKLIST_REGEX, + ) self.result.is_error(f"<{command.command}> is blocked for security reason") state = True return state async def collect(self) -> None: - """ - Method used to collect outputs of all commands of this test class from the device of this test instance. - """ + """Collect outputs of all commands of this test class from the device of this test instance.""" try: if self.blocked is False: await self.device.collect_commands(self.instance_commands) @@ -438,8 +469,7 @@ async def collect(self) -> None: @staticmethod def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]: - """ - Decorator for the `test()` method. + """Decorate the `test()` method in child classes. This decorator implements (in this order): @@ -453,15 +483,21 @@ def anta_test(function: F) -> Callable[..., Coroutine[Any, Any, TestResult]]: async def wrapper( self: AntaTest, eos_data: list[dict[Any, Any] | str] | None = None, - **kwargs: Any, + **kwargs: dict[str, Any], ) -> TestResult: - """ + """Inner function for the anta_test decorator. + Args: + ---- + self: The test instance. eos_data: Populate outputs of the test commands instead of collecting from devices. This list must have the same length and order than the `instance_commands` instance attribute. + kwargs: Any keyword argument to pass to the test. Returns: + ------- result: TestResult instance attribute populated with error status if any + """ def format_td(seconds: float, digits: int = 3) -> str: @@ -475,7 +511,7 @@ def format_td(seconds: float, digits: int = 3) -> str: # Data if eos_data is not None: self.save_commands_data(eos_data) - self.logger.debug(f"Test {self.name} initialized with input data {eos_data}") + self.logger.debug("Test %s initialized with input data %s", self.name, eos_data) # If some data is missing, try to collect if not self.collected: @@ -488,7 +524,8 @@ def format_td(seconds: float, digits: int = 3) -> str: unsupported_commands = [f"Skipped because {c.command} is not supported on {self.device.hw_model}" for c in cmds if not self.device.supports(c)] self.logger.debug(unsupported_commands) if unsupported_commands: - self.logger.warning(f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}") + msg = f"Test {self.name} has been skipped because it is not supported on {self.device.hw_model}: {GITHUB_SUGGESTION}" + self.logger.warning(msg) self.result.is_skipped("\n".join(unsupported_commands)) return self.result self.result.is_error(message="\n".join([f"{c.command} has failed: {', '.join(c.errors)}" for c in cmds])) @@ -505,7 +542,8 @@ def format_td(seconds: float, digits: int = 3) -> str: self.result.is_error(message=exc_to_str(e)) test_duration = time.time() - start_time - self.logger.debug(f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}") + msg = f"Executing test {self.name} on device {self.device.name} took {format_td(test_duration)}" + self.logger.debug(msg) AntaTest.update_progress() return self.result @@ -513,21 +551,20 @@ def format_td(seconds: float, digits: int = 3) -> str: return wrapper @classmethod - def update_progress(cls) -> None: - """ - Update progress bar for all AntaTest objects if it exists - """ + def update_progress(cls: type[AntaTest]) -> None: + """Update progress bar for all AntaTest objects if it exists.""" if cls.progress and (cls.nrfu_task is not None): cls.progress.update(cls.nrfu_task, advance=1) @abstractmethod def test(self) -> Coroutine[Any, Any, TestResult]: - """ - This abstract method is the core of the test logic. - It must set the correct status of the `result` instance attribute - with the appropriate outcome of the test. + """Core of the test logic. - Examples: + This is an abstractmethod that must be implemented by child classes. + It must set the correct status of the `result` instance attribute with the appropriate outcome of the test. + + Examples + -------- It must be implemented using the `AntaTest.anta_test` decorator: ```python @AntaTest.anta_test @@ -537,4 +574,5 @@ def test(self) -> None: if not self._test_command(command): # _test_command() is an arbitrary test logic self.result.is_failure("Failure reson") ``` + """ diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py index dda9d9c63..41926e887 100644 --- a/anta/reporter/__init__.py +++ b/anta/reporter/__init__.py @@ -1,23 +1,24 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Report management for ANTA. -""" +"""Report management for ANTA.""" + # pylint: disable = too-few-public-methods from __future__ import annotations import logging -import os.path -import pathlib -from typing import Any, Optional +from typing import TYPE_CHECKING, Any from jinja2 import Template from rich.table import Table from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME -from anta.custom_types import TestStatus -from anta.result_manager import ResultManager + +if TYPE_CHECKING: + import pathlib + + from anta.custom_types import TestStatus + from anta.result_manager import ResultManager logger = logging.getLogger(__name__) @@ -25,33 +26,37 @@ class ReportTable: """TableReport Generate a Table based on TestResult.""" - def _split_list_to_txt_list(self, usr_list: list[str], delimiter: Optional[str] = None) -> str: - """ - Split list to multi-lines string + def _split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None = None) -> str: + """Split list to multi-lines string. Args: + ---- usr_list (list[str]): List of string to concatenate delimiter (str, optional): A delimiter to use to start string. Defaults to None. Returns: + ------- str: Multi-lines string + """ if delimiter is not None: return "\n".join(f"{delimiter} {line}" for line in usr_list) return "\n".join(f"{line}" for line in usr_list) def _build_headers(self, headers: list[str], table: Table) -> Table: - """ - Create headers for a table. + """Create headers for a table. First key is considered as header and is colored using RICH_COLOR_PALETTE.HEADER Args: + ---- headers (list[str]): List of headers table (Table): A rich Table instance Returns: + ------- Table: A rich Table instance with headers + """ for idx, header in enumerate(headers): if idx == 0: @@ -64,14 +69,16 @@ def _build_headers(self, headers: list[str], table: Table) -> Table: return table def _color_result(self, status: TestStatus) -> str: - """ - Return a colored string based on the status value. + """Return a colored string based on the status value. Args: + ---- status (TestStatus): status value to color Returns: + ------- str: the colored string + """ color = RICH_COLOR_THEME.get(status, "") return f"[{color}]{status}" if color != "" else str(status) @@ -79,23 +86,25 @@ def _color_result(self, status: TestStatus) -> str: def report_all( self, result_manager: ResultManager, - host: Optional[str] = None, - testcase: Optional[str] = None, + host: str | None = None, + testcase: str | None = None, title: str = "All tests results", ) -> Table: - """ - Create a table report with all tests for one or all devices. + """Create a table report with all tests for one or all devices. Create table with full output: Host / Test / Status / Message Args: + ---- result_manager (ResultManager): A manager with a list of tests. host (str, optional): IP Address of a host to search for. Defaults to None. testcase (str, optional): A test name to search for. Defaults to None. title (str, optional): Title for the report. Defaults to 'All tests results'. Returns: + ------- Table: A fully populated rich Table + """ table = Table(title=title, show_lines=True) headers = ["Device", "Test Name", "Test Status", "Message(s)", "Test description", "Test category"] @@ -113,21 +122,23 @@ def report_all( def report_summary_tests( self, result_manager: ResultManager, - testcase: Optional[str] = None, + testcase: str | None = None, title: str = "Summary per test case", ) -> Table: - """ - Create a table report with result agregated per test. + """Create a table report with result agregated per test. Create table with full output: Test / Number of success / Number of failure / Number of error / List of nodes in error or failure Args: + ---- result_manager (ResultManager): A manager with a list of tests. testcase (str, optional): A test name to search for. Defaults to None. title (str, optional): Title for the report. Defaults to 'All tests results'. Returns: + ------- Table: A fully populated rich Table + """ # sourcery skip: class-extract-method table = Table(title=title, show_lines=True) @@ -161,21 +172,23 @@ def report_summary_tests( def report_summary_hosts( self, result_manager: ResultManager, - host: Optional[str] = None, + host: str | None = None, title: str = "Summary per host", ) -> Table: - """ - Create a table report with result agregated per host. + """Create a table report with result agregated per host. Create table with full output: Host / Number of success / Number of failure / Number of error / List of nodes in error or failure Args: + ---- result_manager (ResultManager): A manager with a list of tests. host (str, optional): IP Address of a host to search for. Defaults to None. title (str, optional): Title for the report. Defaults to 'All tests results'. Returns: + ------- Table: A fully populated rich Table + """ table = Table(title=title, show_lines=True) headers = [ @@ -191,7 +204,7 @@ def report_summary_hosts( if host is None or str(host_read) == host: results = result_manager.get_result_by_host(host_read) logger.debug("data to use for computation") - logger.debug(f"{host}: {results}") + logger.debug("%s: %s", host, results) nb_failure = len([result for result in results if result.result == "failure"]) nb_error = len([result for result in results if result.result == "error"]) list_failure = [str(result.test) for result in results if result.result in ["failure", "error"]] @@ -212,14 +225,15 @@ class ReportJinja: """Report builder based on a Jinja2 template.""" def __init__(self, template_path: pathlib.Path) -> None: - if os.path.isfile(template_path): + """Create a ReportJinja instance.""" + if template_path.is_file(): self.tempalte_path = template_path else: - raise FileNotFoundError(f"template file is not found: {template_path}") + msg = f"template file is not found: {template_path}" + raise FileNotFoundError(msg) - def render(self, data: list[dict[str, Any]], trim_blocks: bool = True, lstrip_blocks: bool = True) -> str: - """ - Build a report based on a Jinja2 template + def render(self, data: list[dict[str, Any]], *, trim_blocks: bool = True, lstrip_blocks: bool = True) -> str: + """Build a report based on a Jinja2 template. Report is built based on a J2 template provided by user. Data structure sent to template is: @@ -238,14 +252,17 @@ def render(self, data: list[dict[str, Any]], trim_blocks: bool = True, lstrip_bl ] Args: + ---- data (list[dict[str, Any]]): List of results from ResultManager.get_results trim_blocks (bool, optional): enable trim_blocks for J2 rendering. Defaults to True. lstrip_blocks (bool, optional): enable lstrip_blocks for J2 rendering. Defaults to True. Returns: + ------- str: rendered template + """ - with open(self.tempalte_path, encoding="utf-8") as file_: + with self.tempalte_path.open(encoding="utf-8") as file_: template = Template(file_.read(), trim_blocks=trim_blocks, lstrip_blocks=lstrip_blocks) return template.render({"data": data}) diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py index 00b50bf50..21e54984f 100644 --- a/anta/result_manager/__init__.py +++ b/anta/result_manager/__init__.py @@ -1,28 +1,29 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Result Manager Module for ANTA. -""" +"""Result Manager module for ANTA.""" + from __future__ import annotations import json import logging +from typing import TYPE_CHECKING from pydantic import TypeAdapter from anta.custom_types import TestStatus -from anta.result_manager.models import TestResult + +if TYPE_CHECKING: + from anta.result_manager.models import TestResult logger = logging.getLogger(__name__) class ResultManager: - """ - Helper to manage Test Results and generate reports. - - Examples: + """Helper to manage Test Results and generate reports. + Examples + -------- Create Inventory: inventory_anta = AntaInventory.parse( @@ -63,11 +64,11 @@ class ResultManager: message=None ), ] + """ def __init__(self) -> None: - """ - Class constructor. + """Class constructor. The status of the class is initialized to "unset" @@ -93,101 +94,104 @@ def __init__(self) -> None: self.error_status = False def __len__(self) -> int: - """ - Implement __len__ method to count number of results. - """ + """Implement __len__ method to count number of results.""" return len(self._result_entries) def _update_status(self, test_status: TestStatus) -> None: - """ - Update ResultManager status based on the table above. - """ - ResultValidator = TypeAdapter(TestStatus) - ResultValidator.validate_python(test_status) + """Update ResultManager status based on the table above.""" + result_validator = TypeAdapter(TestStatus) + result_validator.validate_python(test_status) if test_status == "error": self.error_status = True return - if self.status == "unset": - self.status = test_status - elif self.status == "skipped" and test_status in {"success", "failure"}: + if self.status == "unset" or self.status == "skipped" and test_status in {"success", "failure"}: self.status = test_status elif self.status == "success" and test_status == "failure": self.status = "failure" def add_test_result(self, entry: TestResult) -> None: - """Add a result to the list + """Add a result to the list. Args: + ---- entry (TestResult): TestResult data to add to the report + """ logger.debug(entry) self._result_entries.append(entry) self._update_status(entry.result) def add_test_results(self, entries: list[TestResult]) -> None: - """Add a list of results to the list + """Add a list of results to the list. Args: + ---- entries (list[TestResult]): List of TestResult data to add to the report + """ for e in entries: self.add_test_result(e) - def get_status(self, ignore_error: bool = False) -> str: - """ - Returns the current status including error_status if ignore_error is False - """ + def get_status(self, *, ignore_error: bool = False) -> str: + """Return the current status including error_status if ignore_error is False.""" return "error" if self.error_status and not ignore_error else self.status def get_results(self) -> list[TestResult]: - """ - Expose list of all test results in different format + """Expose list of all test results in different format. - Returns: + Returns + ------- any: List of results. + """ return self._result_entries def get_json_results(self) -> str: - """ - Expose list of all test results in JSON + """Expose list of all test results in JSON. - Returns: + Returns + ------- str: JSON dumps of the list of results + """ result = [result.model_dump() for result in self._result_entries] return json.dumps(result, indent=4) def get_result_by_test(self, test_name: str) -> list[TestResult]: - """ - Get list of test result for a given test. + """Get list of test result for a given test. Args: + ---- test_name (str): Test name to use to filter results Returns: + ------- list[TestResult]: List of results related to the test. + """ return [result for result in self._result_entries if str(result.test) == test_name] def get_result_by_host(self, host_ip: str) -> list[TestResult]: - """ - Get list of test result for a given host. + """Get list of test result for a given host. Args: + ---- host_ip (str): IP Address of the host to use to filter results. Returns: + ------- list[TestResult]: List of results related to the host. + """ return [result for result in self._result_entries if str(result.name) == host_ip] def get_testcases(self) -> list[str]: - """ - Get list of name of all test cases in current manager. + """Get list of name of all test cases in current manager. - Returns: + Returns + ------- list[str]: List of names for all tests. + """ result_list = [] for testcase in self._result_entries: @@ -196,11 +200,12 @@ def get_testcases(self) -> list[str]: return result_list def get_hosts(self) -> list[str]: - """ - Get list of IP addresses in current manager. + """Get list of IP addresses in current manager. - Returns: + Returns + ------- list[str]: List of IP addresses. + """ result_list = [] for testcase in self._result_entries: diff --git a/anta/result_manager/models.py b/anta/result_manager/models.py index 153138159..c53947ee4 100644 --- a/anta/result_manager/models.py +++ b/anta/result_manager/models.py @@ -2,10 +2,8 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """Models related to anta.result_manager module.""" -from __future__ import annotations -# Need to keep List for pydantic in 3.8 -from typing import List, Optional +from __future__ import annotations from pydantic import BaseModel @@ -13,10 +11,10 @@ class TestResult(BaseModel): - """ - Describe the result of a test from a single device. + """Describe the result of a test from a single device. - Attributes: + Attributes + ---------- name: Device name where the test has run. test: Test name runs on the device. categories: List of categories the TestResult belongs to, by default the AntaTest categories. @@ -24,63 +22,70 @@ class TestResult(BaseModel): result: Result of the test. Can be one of "unset", "success", "failure", "error" or "skipped". messages: Message to report after the test if any. custom_field: Custom field to store a string for flexibility in integrating with ANTA + """ name: str test: str - categories: List[str] + categories: list[str] description: str result: TestStatus = "unset" - messages: List[str] = [] - custom_field: Optional[str] = None + messages: list[str] = [] + custom_field: str | None = None def is_success(self, message: str | None = None) -> None: - """ - Helper to set status to success + """Set status to success. Args: + ---- message: Optional message related to the test + """ self._set_status("success", message) def is_failure(self, message: str | None = None) -> None: - """ - Helper to set status to failure + """Set status to failure. Args: + ---- message: Optional message related to the test + """ self._set_status("failure", message) def is_skipped(self, message: str | None = None) -> None: - """ - Helper to set status to skipped + """Set status to skipped. Args: + ---- message: Optional message related to the test + """ self._set_status("skipped", message) def is_error(self, message: str | None = None) -> None: - """ - Helper to set status to error + """Set status to error. + + Args: + ---- + message: Optional message related to the test + """ self._set_status("error", message) def _set_status(self, status: TestStatus, message: str | None = None) -> None: - """ - Set status and insert optional message + """Set status and insert optional message. Args: + ---- status: status of the test message: optional message + """ self.result = status if message is not None: self.messages.append(message) def __str__(self) -> str: - """ - Returns a human readable string of this TestResult - """ + """Return a human readable string of this TestResult.""" return f"Test '{self.test}' (on '{self.name}'): Result '{self.result}'\nMessages: {self.messages}" diff --git a/anta/runner.py b/anta/runner.py index ab65c8034..e7967b0ad 100644 --- a/anta/runner.py +++ b/anta/runner.py @@ -2,34 +2,60 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: disable=too-many-branches -""" -ANTA runner function -""" +"""ANTA runner function.""" + from __future__ import annotations import asyncio import logging -from typing import Tuple +from typing import TYPE_CHECKING, Tuple from anta import GITHUB_SUGGESTION from anta.catalog import AntaCatalog, AntaTestDefinition from anta.device import AntaDevice -from anta.inventory import AntaInventory from anta.logger import anta_log_exception from anta.models import AntaTest -from anta.result_manager import ResultManager + +if TYPE_CHECKING: + from anta.inventory import AntaInventory + from anta.result_manager import ResultManager logger = logging.getLogger(__name__) AntaTestRunner = Tuple[AntaTestDefinition, AntaDevice] -async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, established_only: bool = True) -> None: +def log_cache_statistics(devices: list[AntaDevice]) -> None: + """Log cache statistics for each device in the inventory. + + Args: + ---- + devices: List of devices in the inventory. + + Returns: + ------- + None: Log the cache statistics for each device in the inventory. + """ - Main coroutine to run ANTA. + for device in devices: + if device.cache_statistics is not None: + msg = ( + f"Cache statistics for '{device.name}': " + f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} " + f"command(s) ({device.cache_statistics['cache_hit_ratio']})" + ) + logger.info(msg) + else: + logger.info("Caching is not enabled on %s", device.name) + + +async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCatalog, tags: list[str] | None = None, *, established_only: bool = True) -> None: + """Run ANTA. + Use this as an entrypoint to the test framwork in your script. Args: + ---- manager: ResultManager object to populate with the test results. inventory: AntaInventory object that includes the device(s). catalog: AntaCatalog object that includes the list of tests. @@ -37,7 +63,9 @@ async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCa established_only: Include only established device(s). Defaults to True. Returns: + ------- any: ResultManager object gets updated with the test results. + """ if not catalog.tests: logger.info("The list of tests is empty, exiting") @@ -49,11 +77,11 @@ async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCa devices: list[AntaDevice] = list(inventory.get_inventory(established_only=established_only, tags=tags).values()) if not devices: - logger.info( - f"No device in the established state '{established_only}' " - f"{f'matching the tags {tags} ' if tags else ''}was found. There is no device to run tests against, exiting" + msg = ( + f"No device in the established state '{established_only}' {f'matching the tags {tags} ' if tags else ''}was found. " + "There is no device to run tests against, exiting" ) - + logger.info(msg) return coros = [] # Using a set to avoid inserting duplicate tests @@ -72,7 +100,8 @@ async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCa tests: list[AntaTestRunner] = list(tests_set) if not tests: - logger.info(f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory. " "Exiting...") + msg = f"There is no tests{f' matching the tags {tags} ' if tags else ' '}to run on current inventory, exiting" + logger.info(msg) return for test_definition, device in tests: @@ -88,9 +117,10 @@ async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCa [ f"There is an error when creating test {test_definition.test.__module__}.{test_definition.test.__name__}.", f"If this is not a custom test implementation: {GITHUB_SUGGESTION}", - ] + ], ) anta_log_exception(e, message, logger) + if AntaTest.progress is not None: AntaTest.nrfu_task = AntaTest.progress.add_task("Running NRFU Tests...", total=len(coros)) @@ -98,12 +128,5 @@ async def main(manager: ResultManager, inventory: AntaInventory, catalog: AntaCa test_results = await asyncio.gather(*coros) for r in test_results: manager.add_test_result(r) - for device in devices: - if device.cache_statistics is not None: - logger.info( - f"Cache statistics for '{device.name}': " - f"{device.cache_statistics['cache_hits']} hits / {device.cache_statistics['total_commands_sent']} " - f"command(s) ({device.cache_statistics['cache_hit_ratio']})" - ) - else: - logger.info(f"Caching is not enabled on {device.name}") + + log_cache_statistics(devices) diff --git a/anta/tests/__init__.py b/anta/tests/__init__.py index e772bee41..ec0b1ec9c 100644 --- a/anta/tests/__init__.py +++ b/anta/tests/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 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 related to all ANTA tests.""" diff --git a/anta/tests/aaa.py b/anta/tests/aaa.py index 84298cf5d..712cab3d0 100644 --- a/anta/tests/aaa.py +++ b/anta/tests/aaa.py @@ -1,44 +1,46 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the EOS various AAA settings -""" +"""Module related to the EOS various AAA tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations from ipaddress import IPv4Address - -# Need to keep List and Set for pydantic in python 3.8 -from typing import List, Literal, Set +from typing import TYPE_CHECKING, ClassVar, Literal from anta.custom_types import AAAAuthMethod from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyTacacsSourceIntf(AntaTest): - """ - Verifies TACACS source-interface for a specified VRF. + """Verifies TACACS source-interface for a specified VRF. Expected Results: - * success: The test will pass if the provided TACACS source-interface is configured in the specified VRF. - * failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF. + * Success: The test will pass if the provided TACACS source-interface is configured in the specified VRF. + * Failure: The test will fail if the provided TACACS source-interface is NOT configured in the specified VRF. """ name = "VerifyTacacsSourceIntf" description = "Verifies TACACS source-interface for a specified VRF." - categories = ["aaa"] - commands = [AntaCommand(command="show tacacs")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs")] + + class Input(AntaTest.Input): + """Input model for the VerifyTacacsSourceIntf test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring intf: str - """Source-interface to use as source IP of TACACS messages""" + """Source-interface to use as source IP of TACACS messages.""" vrf: str = "default" - """The name of the VRF to transport TACACS messages""" + """The name of the VRF to transport TACACS messages. Defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTacacsSourceIntf.""" command_output = self.instance_commands[0].json_output try: if command_output["srcIntf"][self.inputs.vrf] == self.inputs.intf: @@ -50,27 +52,29 @@ def test(self) -> None: class VerifyTacacsServers(AntaTest): - """ - Verifies TACACS servers are configured for a specified VRF. + """Verifies TACACS servers are configured for a specified VRF. Expected Results: - * success: The test will pass if the provided TACACS servers are configured in the specified VRF. - * failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF. + * Success: The test will pass if the provided TACACS servers are configured in the specified VRF. + * Failure: The test will fail if the provided TACACS servers are NOT configured in the specified VRF. """ name = "VerifyTacacsServers" description = "Verifies TACACS servers are configured for a specified VRF." - categories = ["aaa"] - commands = [AntaCommand(command="show tacacs")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - servers: List[IPv4Address] - """List of TACACS servers""" + class Input(AntaTest.Input): + """Input model for the VerifyTacacsServers test.""" + + servers: list[IPv4Address] + """List of TACACS servers.""" vrf: str = "default" - """The name of the VRF to transport TACACS messages""" + """The name of the VRF to transport TACACS messages. Defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTacacsServers.""" command_output = self.instance_commands[0].json_output tacacs_servers = command_output["tacacsServers"] if not tacacs_servers: @@ -90,25 +94,27 @@ def test(self) -> None: class VerifyTacacsServerGroups(AntaTest): - """ - Verifies if the provided TACACS server group(s) are configured. + """Verifies if the provided TACACS server group(s) are configured. Expected Results: - * success: The test will pass if the provided TACACS server group(s) are configured. - * failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured. + * Success: The test will pass if the provided TACACS server group(s) are configured. + * Failure: The test will fail if one or all the provided TACACS server group(s) are NOT configured. """ name = "VerifyTacacsServerGroups" description = "Verifies if the provided TACACS server group(s) are configured." - categories = ["aaa"] - commands = [AntaCommand(command="show tacacs")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show tacacs")] + + class Input(AntaTest.Input): + """Input model for the VerifyTacacsServerGroups test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - groups: List[str] - """List of TACACS server group""" + groups: list[str] + """List of TACACS server groups.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTacacsServerGroups.""" command_output = self.instance_commands[0].json_output tacacs_groups = command_output["groups"] if not tacacs_groups: @@ -122,29 +128,31 @@ def test(self) -> None: class VerifyAuthenMethods(AntaTest): - """ - Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x). + """Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x). Expected Results: - * success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types. - * failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types. + * Success: The test will pass if the provided AAA authentication method list is matching in the configured authentication types. + * Failure: The test will fail if the provided AAA authentication method list is NOT matching in the configured authentication types. """ name = "VerifyAuthenMethods" description = "Verifies the AAA authentication method lists for different authentication types (login, enable, dot1x)." - categories = ["aaa"] - commands = [AntaCommand(command="show aaa methods authentication")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authentication")] + + class Input(AntaTest.Input): + """Input model for the VerifyAuthenMethods test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - methods: List[AAAAuthMethod] - """List of AAA authentication methods. Methods should be in the right order""" - types: Set[Literal["login", "enable", "dot1x"]] - """List of authentication types to verify""" + methods: list[AAAAuthMethod] + """List of AAA authentication methods. Methods should be in the right order.""" + types: set[Literal["login", "enable", "dot1x"]] + """List of authentication types to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAuthenMethods.""" command_output = self.instance_commands[0].json_output - not_matching = [] + not_matching: list[str] = [] for k, v in command_output.items(): auth_type = k.replace("AuthenMethods", "") if auth_type not in self.inputs.types: @@ -157,9 +165,8 @@ def test(self) -> None: if v["login"]["methods"] != self.inputs.methods: self.result.is_failure(f"AAA authentication methods {self.inputs.methods} are not matching for login console") return - for methods in v.values(): - if methods["methods"] != self.inputs.methods: - not_matching.append(auth_type) + not_matching.extend(auth_type for methods in v.values() if methods["methods"] != self.inputs.methods) + if not not_matching: self.result.is_success() else: @@ -167,37 +174,38 @@ def test(self) -> None: class VerifyAuthzMethods(AntaTest): - """ - Verifies the AAA authorization method lists for different authorization types (commands, exec). + """Verifies the AAA authorization method lists for different authorization types (commands, exec). Expected Results: - * success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types. - * failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types. + * Success: The test will pass if the provided AAA authorization method list is matching in the configured authorization types. + * Failure: The test will fail if the provided AAA authorization method list is NOT matching in the configured authorization types. """ name = "VerifyAuthzMethods" description = "Verifies the AAA authorization method lists for different authorization types (commands, exec)." - categories = ["aaa"] - commands = [AntaCommand(command="show aaa methods authorization")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods authorization")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - methods: List[AAAAuthMethod] - """List of AAA authorization methods. Methods should be in the right order""" - types: Set[Literal["commands", "exec"]] - """List of authorization types to verify""" + class Input(AntaTest.Input): + """Input model for the VerifyAuthzMethods test.""" + + methods: list[AAAAuthMethod] + """List of AAA authorization methods. Methods should be in the right order.""" + types: set[Literal["commands", "exec"]] + """List of authorization types to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAuthzMethods.""" command_output = self.instance_commands[0].json_output - not_matching = [] + not_matching: list[str] = [] for k, v in command_output.items(): authz_type = k.replace("AuthzMethods", "") if authz_type not in self.inputs.types: # We do not need to verify this accounting type continue - for methods in v.values(): - if methods["methods"] != self.inputs.methods: - not_matching.append(authz_type) + not_matching.extend(authz_type for methods in v.values() if methods["methods"] != self.inputs.methods) + if not not_matching: self.result.is_success() else: @@ -205,27 +213,29 @@ def test(self) -> None: class VerifyAcctDefaultMethods(AntaTest): - """ - Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x). + """Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x). Expected Results: - * success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types. - * failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types. + * Success: The test will pass if the provided AAA accounting default method list is matching in the configured accounting types. + * Failure: The test will fail if the provided AAA accounting default method list is NOT matching in the configured accounting types. """ name = "VerifyAcctDefaultMethods" description = "Verifies the AAA accounting default method lists for different accounting types (system, exec, commands, dot1x)." - categories = ["aaa"] - commands = [AntaCommand(command="show aaa methods accounting")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting")] + + class Input(AntaTest.Input): + """Input model for the VerifyAcctDefaultMethods test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - methods: List[AAAAuthMethod] - """List of AAA accounting methods. Methods should be in the right order""" - types: Set[Literal["commands", "exec", "system", "dot1x"]] - """List of accounting types to verify""" + methods: list[AAAAuthMethod] + """List of AAA accounting methods. Methods should be in the right order.""" + types: set[Literal["commands", "exec", "system", "dot1x"]] + """List of accounting types to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAcctDefaultMethods.""" command_output = self.instance_commands[0].json_output not_matching = [] not_configured = [] @@ -249,27 +259,29 @@ def test(self) -> None: class VerifyAcctConsoleMethods(AntaTest): - """ - Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x). + """Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x). Expected Results: - * success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types. - * failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types. + * Success: The test will pass if the provided AAA accounting console method list is matching in the configured accounting types. + * Failure: The test will fail if the provided AAA accounting console method list is NOT matching in the configured accounting types. """ name = "VerifyAcctConsoleMethods" description = "Verifies the AAA accounting console method lists for different accounting types (system, exec, commands, dot1x)." - categories = ["aaa"] - commands = [AntaCommand(command="show aaa methods accounting")] + categories: ClassVar[list[str]] = ["aaa"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa methods accounting")] + + class Input(AntaTest.Input): + """Input model for the VerifyAcctConsoleMethods test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - methods: List[AAAAuthMethod] - """List of AAA accounting console methods. Methods should be in the right order""" - types: Set[Literal["commands", "exec", "system", "dot1x"]] - """List of accounting console types to verify""" + methods: list[AAAAuthMethod] + """List of AAA accounting console methods. Methods should be in the right order.""" + types: set[Literal["commands", "exec", "system", "dot1x"]] + """List of accounting console types to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAcctConsoleMethods.""" command_output = self.instance_commands[0].json_output not_matching = [] not_configured = [] diff --git a/anta/tests/bfd.py b/anta/tests/bfd.py index aea8d07b0..04bcdac67 100644 --- a/anta/tests/bfd.py +++ b/anta/tests/bfd.py @@ -1,16 +1,15 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -BFD test functions -""" +"""Module related to BFD tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from ipaddress import IPv4Address -from typing import Any, List, Optional +from typing import TYPE_CHECKING, Any, ClassVar from pydantic import BaseModel, Field @@ -18,41 +17,40 @@ from anta.models import AntaCommand, AntaTest from anta.tools.get_value import get_value +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyBFDSpecificPeers(AntaTest): - """ - This class verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF. + """Verifies if the IPv4 BFD peer's sessions are UP and remote disc is non-zero in the specified VRF. Expected results: - * success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF. - * failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF. + * Success: The test will pass if IPv4 BFD peers are up and remote disc is non-zero in the specified VRF. + * Failure: The test will fail if IPv4 BFD peers are not found, the status is not UP or remote disc is zero in the specified VRF. """ name = "VerifyBFDSpecificPeers" description = "Verifies the IPv4 BFD peer's sessions and remote disc in the specified VRF." - categories = ["bfd"] - commands = [AntaCommand(command="show bfd peers")] + categories: ClassVar[list[str]] = ["bfd"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers")] class Input(AntaTest.Input): - """ - This class defines the input parameters of the test case. - """ + """Input model for the VerifyBFDSpecificPeers test.""" - bfd_peers: List[BFDPeers] - """List of IPv4 BFD peers""" + bfd_peers: list[BFDPeer] + """List of IPv4 BFD peers.""" - class BFDPeers(BaseModel): - """ - This class defines the details of an IPv4 BFD peer. - """ + class BFDPeer(BaseModel): + """Model for an IPv4 BFD peer.""" peer_address: IPv4Address - """IPv4 address of a BFD peer""" + """IPv4 address of a BFD peer.""" vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + """Optional VRF for BFD peer. If not provided, it defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBFDSpecificPeers.""" failures: dict[Any, Any] = {} # Iterating over BFD peers @@ -77,45 +75,41 @@ def test(self) -> None: class VerifyBFDPeersIntervals(AntaTest): - """ - This class verifies the timers of the IPv4 BFD peers in the specified VRF. + """Verifies the timers of the IPv4 BFD peers in the specified VRF. Expected results: - * success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF. - * failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF. + * Success: The test will pass if the timers of the IPv4 BFD peers are correct in the specified VRF. + * Failure: The test will fail if the IPv4 BFD peers are not found or their timers are incorrect in the specified VRF. """ name = "VerifyBFDPeersIntervals" description = "Verifies the timers of the IPv4 BFD peers in the specified VRF." - categories = ["bfd"] - commands = [AntaCommand(command="show bfd peers detail")] + categories: ClassVar[list[str]] = ["bfd"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers detail")] class Input(AntaTest.Input): - """ - This class defines the input parameters of the test case. - """ + """Input model for the VerifyBFDPeersIntervals test.""" - bfd_peers: List[BFDPeers] - """List of BFD peers""" + bfd_peers: list[BFDPeer] + """List of BFD peers.""" - class BFDPeers(BaseModel): - """ - This class defines the details of an IPv4 BFD peer. - """ + class BFDPeer(BaseModel): + """Model for an IPv4 BFD peer.""" peer_address: IPv4Address - """IPv4 address of a BFD peer""" + """IPv4 address of a BFD peer.""" vrf: str = "default" - """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" + """Optional VRF for BFD peer. If not provided, it defaults to `default`.""" tx_interval: BfdInterval - """Tx interval of BFD peer in milliseconds""" + """Tx interval of BFD peer in milliseconds.""" rx_interval: BfdInterval - """Rx interval of BFD peer in milliseconds""" + """Rx interval of BFD peer in milliseconds.""" multiplier: BfdMultiplier - """Multiplier of BFD peer""" + """Multiplier of BFD peer.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBFDPeersIntervals.""" failures: dict[Any, Any] = {} # Iterating over BFD peers @@ -157,10 +151,10 @@ def test(self) -> None: class VerifyBFDPeersHealth(AntaTest): - """ - This class verifies the health of IPv4 BFD peers across all VRFs. + """Verifies the health of IPv4 BFD peers across all VRFs. It checks that no BFD peer is in the down state and that the discriminator value of the remote system is not zero. + Optionally, it can also verify that BFD peers have not been down before a specified threshold of hours. Expected results: @@ -172,20 +166,19 @@ class VerifyBFDPeersHealth(AntaTest): name = "VerifyBFDPeersHealth" description = "Verifies the health of all IPv4 BFD peers." - categories = ["bfd"] + categories: ClassVar[list[str]] = ["bfd"] # revision 1 as later revision introduces additional nesting for type - commands = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bfd peers", revision=1), AntaCommand(command="show clock")] class Input(AntaTest.Input): - """ - This class defines the input parameters of the test case. - """ + """Input model for the VerifyBFDPeersHealth test.""" - down_threshold: Optional[int] = Field(default=None, gt=0) + down_threshold: int | None = Field(default=None, gt=0) """Optional down threshold in hours to check if a BFD peer was down before those hours or not.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBFDPeersHealth.""" # Initialize failure strings down_failures = [] up_failures = [] @@ -212,7 +205,9 @@ def test(self) -> None: remote_disc = peer_data["remoteDisc"] remote_disc_info = f" with remote disc {remote_disc}" if remote_disc == 0 else "" last_down = peer_data["lastDown"] - hours_difference = (datetime.fromtimestamp(current_timestamp) - datetime.fromtimestamp(last_down)).total_seconds() / 3600 + hours_difference = ( + datetime.fromtimestamp(current_timestamp, tz=timezone.utc) - datetime.fromtimestamp(last_down, tz=timezone.utc) + ).total_seconds() / 3600 # Check if peer status is not up if peer_status != "up": diff --git a/anta/tests/configuration.py b/anta/tests/configuration.py index 060782af0..5d1af0c54 100644 --- a/anta/tests/configuration.py +++ b/anta/tests/configuration.py @@ -1,30 +1,37 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the device configuration -""" +"""Module related to the device configuration tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations +from typing import TYPE_CHECKING, ClassVar + from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyZeroTouch(AntaTest): - """ - Verifies ZeroTouch is disabled + """Verifies ZeroTouch is disabled. + + Expected Results: + * Success: The test will pass if ZeroTouch is disabled. + * Failure: The test will fail if ZeroTouch is enabled. """ name = "VerifyZeroTouch" description = "Verifies ZeroTouch is disabled" - categories = ["configuration"] - commands = [AntaCommand(command="show zerotouch")] + categories: ClassVar[list[str]] = ["configuration"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show zerotouch")] @AntaTest.anta_test def test(self) -> None: - command_output = self.instance_commands[0].output - assert isinstance(command_output, dict) + """Main test function for VerifyZeroTouch.""" + command_output = self.instance_commands[0].json_output if command_output["mode"] == "disabled": self.result.is_success() else: @@ -32,20 +39,24 @@ def test(self) -> None: class VerifyRunningConfigDiffs(AntaTest): - """ - Verifies there is no difference between the running-config and the startup-config + """Verifies there is no difference between the running-config and the startup-config. + + Expected Results: + * Success: The test will pass if there is no difference between the running-config and the startup-config. + * Failure: The test will fail if there is a difference between the running-config and the startup-config. + """ name = "VerifyRunningConfigDiffs" description = "Verifies there is no difference between the running-config and the startup-config" - categories = ["configuration"] - commands = [AntaCommand(command="show running-config diffs", ofmt="text")] + categories: ClassVar[list[str]] = ["configuration"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show running-config diffs", ofmt="text")] @AntaTest.anta_test def test(self) -> None: - command_output = self.instance_commands[0].output - if command_output is None or command_output == "": + """Main test function for VerifyRunningConfigDiffs.""" + command_output = self.instance_commands[0].text_output + if command_output == "": self.result.is_success() else: - self.result.is_failure() - self.result.is_failure(str(command_output)) + self.result.is_failure(command_output) diff --git a/anta/tests/connectivity.py b/anta/tests/connectivity.py index 7222f5632..a14022ec0 100644 --- a/anta/tests/connectivity.py +++ b/anta/tests/connectivity.py @@ -1,59 +1,59 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to various connectivity checks -""" +"""Module related to various connectivity tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations from ipaddress import IPv4Address - -# Need to keep List for pydantic in python 3.8 -from typing import List, Union +from typing import ClassVar from pydantic import BaseModel from anta.custom_types import Interface -from anta.models import AntaCommand, AntaMissingParamException, AntaTemplate, AntaTest +from anta.models import AntaCommand, AntaMissingParamError, AntaTemplate, AntaTest class VerifyReachability(AntaTest): - """ - Test network reachability to one or many destination IP(s). + """Test network reachability to one or many destination IP(s). Expected Results: - * success: The test will pass if all destination IP(s) are reachable. - * failure: The test will fail if one or many destination IP(s) are unreachable. + * Success: The test will pass if all destination IP(s) are reachable. + * Failure: The test will fail if one or many destination IP(s) are unreachable. """ name = "VerifyReachability" description = "Test the network reachability to one or many destination IP(s)." - categories = ["connectivity"] - commands = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")] + categories: ClassVar[list[str]] = ["connectivity"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="ping vrf {vrf} {destination} source {source} repeat {repeat}")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - hosts: List[Host] - """List of hosts to ping""" + class Input(AntaTest.Input): + """Input model for the VerifyReachability test.""" + + hosts: list[Host] + """List of host to ping.""" class Host(BaseModel): - """Remote host to ping""" + """Model for a remote host to ping.""" destination: IPv4Address - """IPv4 address to ping""" - source: Union[IPv4Address, Interface] - """IPv4 address source IP or Egress interface to use""" + """IPv4 address to ping.""" + source: IPv4Address | Interface + """IPv4 address source IP or egress interface to use.""" vrf: str = "default" - """VRF context""" + """VRF context. Defaults to `default`.""" repeat: int = 2 - """Number of ping repetition (default=2)""" + """Number of ping repetition. Defaults to 2.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each host in the input list.""" return [template.render(destination=host.destination, source=host.source, vrf=host.vrf, repeat=host.repeat) for host in self.inputs.hosts] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyReachability.""" failures = [] for command in self.instance_commands: src = command.params.get("source") @@ -61,7 +61,8 @@ def test(self) -> None: repeat = command.params.get("repeat") if any(elem is None for elem in (src, dst, repeat)): - raise AntaMissingParamException(f"A parameter is missing to execute the test for command {command}") + msg = f"A parameter is missing to execute the test for command {command}" + raise AntaMissingParamError(msg) if f"{repeat} received" not in command.json_output["messages"][0]: failures.append((str(src), str(dst))) @@ -73,37 +74,39 @@ def test(self) -> None: class VerifyLLDPNeighbors(AntaTest): - """ - This test verifies that the provided LLDP neighbors are present and connected with the correct configuration. + """Verifies that the provided LLDP neighbors are present and connected with the correct configuration. Expected Results: - * success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device. - * failure: The test will fail if any of the following conditions are met: + * Success: The test will pass if each of the provided LLDP neighbors is present and connected to the specified port and device. + * Failure: The test will fail if any of the following conditions are met: - The provided LLDP neighbor is not found. - The system name or port of the LLDP neighbor does not match the provided information. """ name = "VerifyLLDPNeighbors" description = "Verifies that the provided LLDP neighbors are connected properly." - categories = ["connectivity"] - commands = [AntaCommand(command="show lldp neighbors detail")] + categories: ClassVar[list[str]] = ["connectivity"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lldp neighbors detail")] + + class Input(AntaTest.Input): + """Input model for the VerifyLLDPNeighbors test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - neighbors: List[Neighbor] - """List of LLDP neighbors""" + neighbors: list[Neighbor] + """List of LLDP neighbors.""" class Neighbor(BaseModel): - """LLDP neighbor""" + """Model for an LLDP neighbor.""" port: Interface - """LLDP port""" + """LLDP port.""" neighbor_device: str - """LLDP neighbor device""" + """LLDP neighbor device.""" neighbor_port: Interface - """LLDP neighbor port""" + """LLDP neighbor port.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLLDPNeighbors.""" command_output = self.instance_commands[0].json_output failures: dict[str, list[str]] = {} diff --git a/anta/tests/field_notices.py b/anta/tests/field_notices.py index 04fdc4d34..167676523 100644 --- a/anta/tests/field_notices.py +++ b/anta/tests/field_notices.py @@ -1,33 +1,40 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions to flag field notices -""" +"""Module related to field notices tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyFieldNotice44Resolution(AntaTest): - """ - Verifies the device is using an Aboot version that fix the bug discussed - in the field notice 44 (Aboot manages system settings prior to EOS initialization). + """Verifies if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44. + + Aboot manages system settings prior to EOS initialization. + + Reference: https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44 - https://www.arista.com/en/support/advisories-notices/field-notice/8756-field-notice-44 + Expected Results: + * Success: The test will pass if the device is using an Aboot version that fixes the bug discussed in the Field Notice 44. + * Failure: The test will fail if the device is not using an Aboot version that fixes the bug discussed in the Field Notice 44. """ name = "VerifyFieldNotice44Resolution" - description = ( - "Verifies the device is using an Aboot version that fix the bug discussed in the field notice 44 (Aboot manages system settings prior to EOS initialization)" - ) - categories = ["field notices", "software"] - commands = [AntaCommand(command="show version detail")] + description = "Verifies that the device is using the correct Aboot version per FN0044." + categories: ClassVar[list[str]] = ["field notices"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail")] - # TODO maybe implement ONLY ON PLATFORMS instead @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyFieldNotice44Resolution.""" command_output = self.instance_commands[0].json_output devices = [ @@ -79,7 +86,6 @@ def test(self) -> None: variants = ["-SSD-F", "-SSD-R", "-M-F", "-M-R", "-F", "-R"] model = command_output["modelName"] - # TODO this list could be a regex for variant in variants: model = model.replace(variant, "") if model not in devices: @@ -90,32 +96,41 @@ def test(self) -> None: if component["name"] == "Aboot": aboot_version = component["version"].split("-")[2] self.result.is_success() - if aboot_version.startswith("4.0.") and int(aboot_version.split(".")[2]) < 7: - self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") - elif aboot_version.startswith("4.1.") and int(aboot_version.split(".")[2]) < 1: - self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") - elif aboot_version.startswith("6.0.") and int(aboot_version.split(".")[2]) < 9: - self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") - elif aboot_version.startswith("6.1.") and int(aboot_version.split(".")[2]) < 7: + incorrect_aboot_version = ( + aboot_version.startswith("4.0.") + and int(aboot_version.split(".")[2]) < 7 + or aboot_version.startswith("4.1.") + and int(aboot_version.split(".")[2]) < 1 + or ( + aboot_version.startswith("6.0.") + and int(aboot_version.split(".")[2]) < 9 + or aboot_version.startswith("6.1.") + and int(aboot_version.split(".")[2]) < 7 + ) + ) + if incorrect_aboot_version: self.result.is_failure(f"device is running incorrect version of aboot ({aboot_version})") class VerifyFieldNotice72Resolution(AntaTest): - """ - Checks if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated. + """Verifies if the device is potentially exposed to Field Notice 72, and if the issue has been mitigated. + + Reference: https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072 - https://www.arista.com/en/support/advisories-notices/field-notice/17410-field-notice-0072 + Expected Results: + * Success: The test will pass if the device is not exposed to FN72 and the issue has been mitigated. + * Failure: The test will fail if the device is exposed to FN72 and the issue has not been mitigated. """ name = "VerifyFieldNotice72Resolution" - description = "Verifies if the device has exposeure to FN72, and if the issue has been mitigated" - categories = ["field notices", "software"] - commands = [AntaCommand(command="show version detail")] + description = "Verifies if the device is exposed to FN0072, and if the issue has been mitigated." + categories: ClassVar[list[str]] = ["field notices"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail")] - # TODO maybe implement ONLY ON PLATFORMS instead @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyFieldNotice72Resolution.""" command_output = self.instance_commands[0].json_output devices = ["DCS-7280SR3-48YC8", "DCS-7280SR3K-48YC8"] @@ -151,8 +166,7 @@ def test(self) -> None: self.result.is_skipped("Device not exposed") return - # Because each of the if checks above will return if taken, we only run the long - # check if we get this far + # Because each of the if checks above will return if taken, we only run the long check if we get this far for entry in command_output["details"]["components"]: if entry["name"] == "FixedSystemvrm1": if int(entry["version"]) < 7: @@ -161,5 +175,5 @@ def test(self) -> None: self.result.is_success("FN72 is mitigated") return # We should never hit this point - self.result.is_error(message="Error in running test - FixedSystemvrm1 not found") + self.result.is_error("Error in running test - FixedSystemvrm1 not found") return diff --git a/anta/tests/greent.py b/anta/tests/greent.py index 26271cdf7..e0ebb3e09 100644 --- a/anta/tests/greent.py +++ b/anta/tests/greent.py @@ -1,60 +1,63 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to GreenT (Postcard Telemetry) in EOS -""" +"""Module related to GreenT (Postcard Telemetry) tests.""" + from __future__ import annotations +from typing import TYPE_CHECKING, ClassVar + from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyGreenTCounters(AntaTest): - """ - Verifies whether GRE packets are sent. + """Verifies if the GreenT (GRE Encapsulated Telemetry) counters are incremented. Expected Results: - * success: if >0 gre packets are sent - * failure: if no gre packets are sent + * Success: The test will pass if the GreenT counters are incremented. + * Failure: The test will fail if the GreenT counters are not incremented. """ name = "VerifyGreenTCounters" - description = "Verifies if the greent counters are incremented." - categories = ["greent"] - commands = [AntaCommand(command="show monitor telemetry postcard counters")] + description = "Verifies if the GreenT counters are incremented." + categories: ClassVar[list[str]] = ["greent"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard counters")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyGreenTCounters.""" command_output = self.instance_commands[0].json_output if command_output["grePktSent"] > 0: self.result.is_success() else: - self.result.is_failure("GRE packets are not sent") + self.result.is_failure("GreenT counters are not incremented") class VerifyGreenT(AntaTest): - """ - Verifies whether GreenT policy is created. + """Verifies if a GreenT (GRE Encapsulated Telemetry) policy other than the default is created. Expected Results: - * success: if there exists any policy other than "default" policy. - * failure: if no policy is created. + * Success: The test will pass if a GreenT policy is created other than the default one. + * Failure: The test will fail if no other GreenT policy is created. """ name = "VerifyGreenT" - description = "Verifies whether greent policy is created." - categories = ["greent"] - commands = [AntaCommand(command="show monitor telemetry postcard policy profile")] + description = "Verifies if a GreenT policy is created." + categories: ClassVar[list[str]] = ["greent"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show monitor telemetry postcard policy profile")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyGreenT.""" command_output = self.instance_commands[0].json_output - out = [f"{i} policy is created" for i in command_output["profiles"].keys() if "default" not in i] + profiles = [profile for profile in command_output["profiles"] if profile != "default"] - if len(out) > 0: - for i in out: - self.result.is_success(f"{i} policy is created") + if profiles: + self.result.is_success() else: - self.result.is_failure("policy is not created") + self.result.is_failure("No GreenT policy is created") diff --git a/anta/tests/hardware.py b/anta/tests/hardware.py index 0a149f28a..51edf590c 100644 --- a/anta/tests/hardware.py +++ b/anta/tests/hardware.py @@ -1,41 +1,44 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the hardware or environment -""" +"""Module related to the hardware or environment tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -# Need to keep List for pydantic in python 3.8 -from typing import List +from typing import TYPE_CHECKING, ClassVar from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyTransceiversManufacturers(AntaTest): - """ - This test verifies if all the transceivers come from approved manufacturers. + """Verifies if all the transceivers come from approved manufacturers. Expected Results: - * success: The test will pass if all transceivers are from approved manufacturers. - * failure: The test will fail if some transceivers are from unapproved manufacturers. + * Success: The test will pass if all transceivers are from approved manufacturers. + * Failure: The test will fail if some transceivers are from unapproved manufacturers. """ name = "VerifyTransceiversManufacturers" description = "Verifies if all transceivers come from approved manufacturers." - categories = ["hardware"] - commands = [AntaCommand(command="show inventory", ofmt="json")] + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show inventory", ofmt="json")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - manufacturers: List[str] - """List of approved transceivers manufacturers""" + class Input(AntaTest.Input): + """Input model for the VerifyTransceiversManufacturers test.""" + + manufacturers: list[str] + """List of approved transceivers manufacturers.""" @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTransceiversManufacturers.""" command_output = self.instance_commands[0].json_output wrong_manufacturers = { interface: value["mfgName"] for interface, value in command_output["xcvrSlots"].items() if value["mfgName"] not in self.inputs.manufacturers @@ -47,24 +50,24 @@ def test(self) -> None: class VerifyTemperature(AntaTest): - """ - This test verifies if the device temperature is within acceptable limits. + """Verifies if the device temperature is within acceptable limits. Expected Results: - * success: The test will pass if the device temperature is currently OK: 'temperatureOk'. - * failure: The test will fail if the device temperature is NOT OK. + * Success: The test will pass if the device temperature is currently OK: 'temperatureOk'. + * Failure: The test will fail if the device temperature is NOT OK. """ name = "VerifyTemperature" description = "Verifies the device temperature." - categories = ["hardware"] - commands = [AntaCommand(command="show system environment temperature", ofmt="json")] + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTemperature.""" command_output = self.instance_commands[0].json_output - temperature_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + temperature_status = command_output.get("systemStatus", "") if temperature_status == "temperatureOk": self.result.is_success() else: @@ -72,24 +75,24 @@ def test(self) -> None: class VerifyTransceiversTemperature(AntaTest): - """ - This test verifies if all the transceivers are operating at an acceptable temperature. + """Verifies if all the transceivers are operating at an acceptable temperature. Expected Results: - * success: The test will pass if all transceivers status are OK: 'ok'. - * failure: The test will fail if some transceivers are NOT OK. + * Success: The test will pass if all transceivers status are OK: 'ok'. + * Failure: The test will fail if some transceivers are NOT OK. """ name = "VerifyTransceiversTemperature" description = "Verifies the transceivers temperature." - categories = ["hardware"] - commands = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")] + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment temperature transceiver", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTransceiversTemperature.""" command_output = self.instance_commands[0].json_output - sensors = command_output["tempSensors"] if "tempSensors" in command_output.keys() else "" + sensors = command_output.get("tempSensors", "") wrong_sensors = { sensor["name"]: { "hwStatus": sensor["hwStatus"], @@ -105,50 +108,52 @@ def test(self) -> None: class VerifyEnvironmentSystemCooling(AntaTest): - """ - This test verifies the device's system cooling. + """Verifies the device's system cooling status. Expected Results: - * success: The test will pass if the system cooling status is OK: 'coolingOk'. - * failure: The test will fail if the system cooling status is NOT OK. + * Success: The test will pass if the system cooling status is OK: 'coolingOk'. + * Failure: The test will fail if the system cooling status is NOT OK. """ name = "VerifyEnvironmentSystemCooling" description = "Verifies the system cooling status." - categories = ["hardware"] - commands = [AntaCommand(command="show system environment cooling", ofmt="json")] + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyEnvironmentSystemCooling.""" command_output = self.instance_commands[0].json_output - sys_status = command_output["systemStatus"] if "systemStatus" in command_output.keys() else "" + sys_status = command_output.get("systemStatus", "") self.result.is_success() if sys_status != "coolingOk": self.result.is_failure(f"Device system cooling is not OK: '{sys_status}'") class VerifyEnvironmentCooling(AntaTest): - """ - This test verifies the fans status. + """Verifies the status of power supply fans and all fan trays. Expected Results: - * success: The test will pass if the fans status are within the accepted states list. - * failure: The test will fail if some fans status is not within the accepted states list. + * Success: The test will pass if the fans status are within the accepted states list. + * Failure: The test will fail if some fans status is not within the accepted states list. """ name = "VerifyEnvironmentCooling" description = "Verifies the status of power supply fans and all fan trays." - categories = ["hardware"] - commands = [AntaCommand(command="show system environment cooling", ofmt="json")] + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment cooling", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyEnvironmentCooling test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - states: List[str] - """Accepted states list for fan status""" + states: list[str] + """List of accepted states of fan status.""" @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyEnvironmentCooling.""" command_output = self.instance_commands[0].json_output self.result.is_success() # First go through power supplies fans @@ -164,28 +169,30 @@ def test(self) -> None: class VerifyEnvironmentPower(AntaTest): - """ - This test verifies the power supplies status. + """Verifies the power supplies status. Expected Results: - * success: The test will pass if the power supplies status are within the accepted states list. - * failure: The test will fail if some power supplies status is not within the accepted states list. + * Success: The test will pass if the power supplies status are within the accepted states list. + * Failure: The test will fail if some power supplies status is not within the accepted states list. """ name = "VerifyEnvironmentPower" description = "Verifies the power supplies status." - categories = ["hardware"] - commands = [AntaCommand(command="show system environment power", ofmt="json")] + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system environment power", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyEnvironmentPower test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - states: List[str] - """Accepted states list for power supplies status""" + states: list[str] + """List of accepted states list of power supplies status.""" @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyEnvironmentPower.""" command_output = self.instance_commands[0].json_output - power_supplies = command_output["powerSupplies"] if "powerSupplies" in command_output.keys() else "{}" + power_supplies = command_output.get("powerSupplies", "{}") wrong_power_supplies = { powersupply: {"state": value["state"]} for powersupply, value in dict(power_supplies).items() if value["state"] not in self.inputs.states } @@ -196,24 +203,24 @@ def test(self) -> None: class VerifyAdverseDrops(AntaTest): - """ - This test verifies if there are no adverse drops on DCS7280E and DCS7500E. + """Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches (Arad/Jericho chips). Expected Results: - * success: The test will pass if there are no adverse drops. - * failure: The test will fail if there are adverse drops. + * Success: The test will pass if there are no adverse drops. + * Failure: The test will fail if there are adverse drops. """ name = "VerifyAdverseDrops" - description = "Verifies there are no adverse drops on DCS7280E and DCS7500E" - categories = ["hardware"] - commands = [AntaCommand(command="show hardware counter drop", ofmt="json")] + description = "Verifies there are no adverse drops on DCS-7280 and DCS-7500 family switches." + categories: ClassVar[list[str]] = ["hardware"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware counter drop", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAdverseDrops.""" command_output = self.instance_commands[0].json_output - total_adverse_drop = command_output["totalAdverseDrops"] if "totalAdverseDrops" in command_output.keys() else "" + total_adverse_drop = command_output.get("totalAdverseDrops", "") if total_adverse_drop == 0: self.result.is_success() else: diff --git a/anta/tests/interfaces.py b/anta/tests/interfaces.py index 59d891018..939ccea1a 100644 --- a/anta/tests/interfaces.py +++ b/anta/tests/interfaces.py @@ -1,23 +1,20 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the device interfaces -""" +"""Module related to the device interfaces tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations import re from ipaddress import IPv4Network +from typing import Any, ClassVar, Literal -# Need to keep Dict and List for pydantic in python 3.8 -from typing import Any, Dict, List, Literal, Optional - -from pydantic import BaseModel, conint +from pydantic import BaseModel, Field from pydantic_extra_types.mac_address import MacAddress -from anta.custom_types import Interface, Percent +from anta.custom_types import Interface, Percent, PositiveInteger from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools.get_item import get_item @@ -25,57 +22,54 @@ class VerifyInterfaceUtilization(AntaTest): - """ - Verifies interfaces utilization is below a threshold. + """Verifies that the utilization of interfaces is below a certain threshold. + Load interval (default to 5 minutes) is defined in device configuration. Expected Results: - * success: The test will pass if all interfaces have a usage below the threshold. - * failure: The test will fail if one or more interfaces have a usage above the threshold. + * Success: The test will pass if all interfaces have a usage below the threshold. + * Failure: The test will fail if one or more interfaces have a usage above the threshold. """ name = "VerifyInterfaceUtilization" - description = "Verifies interfaces utilization is below a threshold." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces counters rates"), AntaCommand(command="show interfaces")] + description = "Verifies that the utilization of interfaces is below a certain threshold." + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters rates"), AntaCommand(command="show interfaces")] class Input(AntaTest.Input): - """Input for the VerifyInterfaceUtilization test.""" + """Input model for the VerifyInterfaceUtilization test.""" threshold: Percent = 75.0 - """Interface utilization threshold above which the test will fail""" + """Interface utilization threshold above which the test will fail. Defaults to 75%.""" @AntaTest.anta_test def test(self) -> None: - DUPLEX_FULL = "duplexFull" + """Main test function for VerifyInterfaceUtilization.""" + duplex_full = "duplexFull" failed_interfaces: dict[str, dict[str, float]] = {} rates = self.instance_commands[0].json_output interfaces = self.instance_commands[1].json_output - def _test_intf(intf: str) -> None: - bandwidth = interfaces["interfaces"][intf]["bandwidth"] - - def _test_rate(rate: str) -> None: - usage = rates[rate] / bandwidth * 100 - if usage > self.inputs.threshold: - failed_interfaces.setdefault(intf, {})[rate] = usage - - for rate in ["inBpsRate", "outBpsRate"]: - _test_rate(rate) - - for intf, rates in rates["interfaces"].items(): + for intf, rate in rates["interfaces"].items(): # Assuming the interface is full-duplex in the logic below if "duplex" in interfaces["interfaces"][intf]: - if interfaces["interfaces"][intf]["duplex"] != DUPLEX_FULL: + if interfaces["interfaces"][intf]["duplex"] != duplex_full: self.result.is_error(f"Interface {intf} is not Full-Duplex, VerifyInterfaceUtilization has not been implemented in ANTA") return elif "memberInterfaces" in interfaces["interfaces"][intf]: # This is a Port-Channel for member, stats in interfaces["interfaces"][intf]["memberInterfaces"].items(): - if stats["duplex"] != DUPLEX_FULL: + if stats["duplex"] != duplex_full: self.result.is_error(f"Member {member} of {intf} is not Full-Duplex, VerifyInterfaceUtilization has not been implemented in ANTA") return - _test_intf(intf) + + bandwidth = interfaces["interfaces"][intf]["bandwidth"] + + for bps_rate in ("inBpsRate", "outBpsRate"): + usage = rate[bps_rate] / bandwidth * 100 + if usage > self.inputs.threshold: + failed_interfaces.setdefault(intf, {})[bps_rate] = usage + if not failed_interfaces: self.result.is_success() else: @@ -83,21 +77,21 @@ def _test_rate(rate: str) -> None: class VerifyInterfaceErrors(AntaTest): - """ - This test verifies that interfaces error counters are equal to zero. + """Verifies that the interfaces error counters are equal to zero. Expected Results: - * success: The test will pass if all interfaces have error counters equal to zero. - * failure: The test will fail if one or more interfaces have non-zero error counters. + * Success: The test will pass if all interfaces have error counters equal to zero. + * Failure: The test will fail if one or more interfaces have non-zero error counters. """ name = "VerifyInterfaceErrors" description = "Verifies there are no interface error counters." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces counters errors")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters errors")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyInterfaceErrors.""" command_output = self.instance_commands[0].json_output wrong_interfaces: list[dict[str, dict[str, int]]] = [] for interface, counters in command_output["interfaceErrorCounters"].items(): @@ -110,25 +104,25 @@ def test(self) -> None: class VerifyInterfaceDiscards(AntaTest): - """ - Verifies interfaces packet discard counters are equal to zero. + """Verifies that the interfaces packet discard counters are equal to zero. Expected Results: - * success: The test will pass if all interfaces have discard counters equal to zero. - * failure: The test will fail if one or more interfaces have non-zero discard counters. + * Success: The test will pass if all interfaces have discard counters equal to zero. + * Failure: The test will fail if one or more interfaces have non-zero discard counters. """ name = "VerifyInterfaceDiscards" description = "Verifies there are no interface discard counters." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces counters discards")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces counters discards")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyInterfaceDiscards.""" command_output = self.instance_commands[0].json_output wrong_interfaces: list[dict[str, dict[str, int]]] = [] for interface, outer_v in command_output["interfaces"].items(): - wrong_interfaces.extend({interface: outer_v} for counter, value in outer_v.items() if value > 0) + wrong_interfaces.extend({interface: outer_v} for value in outer_v.values() if value > 0) if not wrong_interfaces: self.result.is_success() else: @@ -136,21 +130,21 @@ def test(self) -> None: class VerifyInterfaceErrDisabled(AntaTest): - """ - Verifies there are no interfaces in errdisabled state. + """Verifies there are no interfaces in the errdisabled state. Expected Results: - * success: The test will pass if there are no interfaces in errdisabled state. - * failure: The test will fail if there is at least one interface in errdisabled state. + * Success: The test will pass if there are no interfaces in the errdisabled state. + * Failure: The test will fail if there is at least one interface in the errdisabled state. """ name = "VerifyInterfaceErrDisabled" description = "Verifies there are no interfaces in the errdisabled state." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces status")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces status")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyInterfaceErrDisabled.""" command_output = self.instance_commands[0].json_output errdisabled_interfaces = [interface for interface, value in command_output["interfaceStatuses"].items() if value["linkStatus"] == "errdisabled"] if errdisabled_interfaces: @@ -160,41 +154,41 @@ def test(self) -> None: class VerifyInterfacesStatus(AntaTest): - """ - This test verifies if the provided list of interfaces are all in the expected state. + """Verifies if the provided list of interfaces are all in the expected state. - If line protocol status is provided, prioritize checking against both status and line protocol status - If line protocol status is not provided and interface status is "up", expect both status and line protocol to be "up" - If interface status is not "up", check only the interface status without considering line protocol status Expected Results: - * success: The test will pass if the provided interfaces are all in the expected state. - * failure: The test will fail if any interface is not in the expected state. + * Success: The test will pass if the provided interfaces are all in the expected state. + * Failure: The test will fail if any interface is not in the expected state. """ name = "VerifyInterfacesStatus" description = "Verifies the status of the provided interfaces." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces description")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description")] class Input(AntaTest.Input): - """Input for the VerifyInterfacesStatus test.""" + """Input model for the VerifyInterfacesStatus test.""" - interfaces: List[InterfaceState] - """List of interfaces to validate with the expected state.""" + interfaces: list[InterfaceState] + """List of interfaces with their expected state.""" class InterfaceState(BaseModel): - """Model for the interface state input.""" + """Model for an interface state.""" name: Interface """Interface to validate.""" status: Literal["up", "down", "adminDown"] """Expected status of the interface.""" - line_protocol_status: Optional[Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"]] = None + line_protocol_status: Literal["up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown"] | None = None """Expected line protocol status of the interface.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyInterfacesStatus.""" command_output = self.instance_commands[0].json_output self.result.is_success() @@ -228,22 +222,22 @@ def test(self) -> None: class VerifyStormControlDrops(AntaTest): - """ - Verifies the device did not drop packets due its to storm-control configuration. + """Verifies there are no interface storm-control drop counters. Expected Results: - * success: The test will pass if there are no storm-control drop counters. - * failure: The test will fail if there is at least one storm-control drop counter. + * Success: The test will pass if there are no storm-control drop counters. + * Failure: The test will fail if there is at least one storm-control drop counter. """ name = "VerifyStormControlDrops" description = "Verifies there are no interface storm-control drop counters." - categories = ["interfaces"] - commands = [AntaCommand(command="show storm-control")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show storm-control")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyStormControlDrops.""" command_output = self.instance_commands[0].json_output storm_controlled_interfaces: dict[str, dict[str, Any]] = {} for interface, interface_dict in command_output["interfaces"].items(): @@ -258,49 +252,49 @@ def test(self) -> None: class VerifyPortChannels(AntaTest): - """ - Verifies there are no inactive ports in all port channels. + """Verifies there are no inactive ports in all port channels. Expected Results: - * success: The test will pass if there are no inactive ports in all port channels. - * failure: The test will fail if there is at least one inactive port in a port channel. + * Success: The test will pass if there are no inactive ports in all port channels. + * Failure: The test will fail if there is at least one inactive port in a port channel. """ name = "VerifyPortChannels" description = "Verifies there are no inactive ports in all port channels." - categories = ["interfaces"] - commands = [AntaCommand(command="show port-channel")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show port-channel")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyPortChannels.""" command_output = self.instance_commands[0].json_output - po_with_invactive_ports: list[dict[str, str]] = [] + po_with_inactive_ports: list[dict[str, str]] = [] for portchannel, portchannel_dict in command_output["portChannels"].items(): if len(portchannel_dict["inactivePorts"]) != 0: - po_with_invactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]}) - if not po_with_invactive_ports: + po_with_inactive_ports.extend({portchannel: portchannel_dict["inactivePorts"]}) + if not po_with_inactive_ports: self.result.is_success() else: - self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_invactive_ports}") + self.result.is_failure(f"The following port-channels have inactive port(s): {po_with_inactive_ports}") class VerifyIllegalLACP(AntaTest): - """ - Verifies there are no illegal LACP packets received. + """Verifies there are no illegal LACP packets in all port channels. Expected Results: - * success: The test will pass if there are no illegal LACP packets received. - * failure: The test will fail if there is at least one illegal LACP packet received. + * Success: The test will pass if there are no illegal LACP packets received. + * Failure: The test will fail if there is at least one illegal LACP packet received. """ name = "VerifyIllegalLACP" description = "Verifies there are no illegal LACP packets in all port channels." - categories = ["interfaces"] - commands = [AntaCommand(command="show lacp counters all-ports")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show lacp counters all-ports")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyIllegalLACP.""" command_output = self.instance_commands[0].json_output po_with_illegal_lacp: list[dict[str, dict[str, int]]] = [] for portchannel, portchannel_dict in command_output["portChannels"].items(): @@ -310,29 +304,31 @@ def test(self) -> None: if not po_with_illegal_lacp: self.result.is_success() else: - self.result.is_failure("The following port-channels have recieved illegal lacp packets on the " f"following ports: {po_with_illegal_lacp}") + self.result.is_failure(f"The following port-channels have received illegal LACP packets on the following ports: {po_with_illegal_lacp}") class VerifyLoopbackCount(AntaTest): - """ - Verifies that the device has the expected number of loopback interfaces and all are operational. + """Verifies that the device has the expected number of loopback interfaces and all are operational. Expected Results: - * success: The test will pass if the device has the correct number of loopback interfaces and none are down. - * failure: The test will fail if the loopback interface count is incorrect or any are non-operational. + * Success: The test will pass if the device has the correct number of loopback interfaces and none are down. + * Failure: The test will fail if the loopback interface count is incorrect or any are non-operational. """ name = "VerifyLoopbackCount" description = "Verifies the number of loopback interfaces and their status." - categories = ["interfaces"] - commands = [AntaCommand(command="show ip interface brief")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type: ignore - """Number of loopback interfaces expected to be present""" + class Input(AntaTest.Input): + """Input model for the VerifyLoopbackCount test.""" + + number: PositiveInteger + """Number of loopback interfaces expected to be present.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoopbackCount.""" command_output = self.instance_commands[0].json_output loopback_count = 0 down_loopback_interfaces = [] @@ -353,28 +349,27 @@ def test(self) -> None: class VerifySVI(AntaTest): - """ - Verifies the status of all SVIs. + """Verifies the status of all SVIs. Expected Results: - * success: The test will pass if all SVIs are up. - * failure: The test will fail if one or many SVIs are not up. + * Success: The test will pass if all SVIs are up. + * Failure: The test will fail if one or many SVIs are not up. """ name = "VerifySVI" description = "Verifies the status of all SVIs." - categories = ["interfaces"] - commands = [AntaCommand(command="show ip interface brief")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip interface brief")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySVI.""" command_output = self.instance_commands[0].json_output down_svis = [] for interface in command_output["interfaces"]: interface_dict = command_output["interfaces"][interface] - if "Vlan" in interface: - if not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"): - down_svis.append(interface) + if "Vlan" in interface and not (interface_dict["lineProtocolStatus"] == "up" and interface_dict["interfaceStatus"] == "connected"): + down_svis.append(interface) if len(down_svis) == 0: self.result.is_success() else: @@ -382,32 +377,35 @@ def test(self) -> None: class VerifyL3MTU(AntaTest): - """ - Verifies the global layer 3 Maximum Transfer Unit (MTU) for all L3 interfaces. + """Verifies the global layer 3 Maximum Transfer Unit (MTU) for all L3 interfaces. Test that L3 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces. - You can define a global MTU to check and also an MTU per interface and also ignored some interfaces. + + You can define a global MTU to check, or an MTU per interface and you can also ignored some interfaces. Expected Results: - * success: The test will pass if all layer 3 interfaces have the proper MTU configured. - * failure: The test will fail if one or many layer 3 interfaces have the wrong MTU configured. + * Success: The test will pass if all layer 3 interfaces have the proper MTU configured. + * Failure: The test will fail if one or many layer 3 interfaces have the wrong MTU configured. """ name = "VerifyL3MTU" description = "Verifies the global L3 MTU of all L3 interfaces." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")] + + class Input(AntaTest.Input): + """Input model for the VerifyL3MTU test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring mtu: int = 1500 - """Default MTU we should have configured on all non-excluded interfaces""" - ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"] + """Default MTU we should have configured on all non-excluded interfaces. Defaults to 1500.""" + ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"]) """A list of L3 interfaces to ignore""" - specific_mtu: List[Dict[str, int]] = [] + specific_mtu: list[dict[str, int]] = Field(default=[]) """A list of dictionary of L3 interfaces with their specific MTU configured""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyL3MTU.""" # Parameter to save incorrect interface settings wrong_l3mtu_intf: list[dict[str, int]] = [] command_output = self.instance_commands[0].json_output @@ -430,28 +428,31 @@ def test(self) -> None: class VerifyIPProxyARP(AntaTest): - """ - Verifies if Proxy-ARP is enabled for the provided list of interface(s). + """Verifies if Proxy-ARP is enabled for the provided list of interface(s). Expected Results: - * success: The test will pass if Proxy-ARP is enabled on the specified interface(s). - * failure: The test will fail if Proxy-ARP is disabled on the specified interface(s). + * Success: The test will pass if Proxy-ARP is enabled on the specified interface(s). + * Failure: The test will fail if Proxy-ARP is disabled on the specified interface(s). """ name = "VerifyIPProxyARP" description = "Verifies if Proxy ARP is enabled." - categories = ["interfaces"] - commands = [AntaTemplate(template="show ip interface {intf}")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {intf}")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - interfaces: List[str] - """list of interfaces to be tested""" + class Input(AntaTest.Input): + """Input model for the VerifyIPProxyARP test.""" + + interfaces: list[str] + """List of interfaces to be tested.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each interface in the input list.""" return [template.render(intf=intf) for intf in self.inputs.interfaces] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyIPProxyARP.""" disabled_intf = [] for command in self.instance_commands: if "intf" in command.params: @@ -465,32 +466,34 @@ def test(self) -> None: class VerifyL2MTU(AntaTest): - """ - Verifies the global layer 2 Maximum Transfer Unit (MTU) for all L2 interfaces. + """Verifies the global layer 2 Maximum Transfer Unit (MTU) for all L2 interfaces. Test that L2 interfaces are configured with the correct MTU. It supports Ethernet, Port Channel and VLAN interfaces. You can define a global MTU to check and also an MTU per interface and also ignored some interfaces. Expected Results: - * success: The test will pass if all layer 2 interfaces have the proper MTU configured. - * failure: The test will fail if one or many layer 2 interfaces have the wrong MTU configured. + * Success: The test will pass if all layer 2 interfaces have the proper MTU configured. + * Failure: The test will fail if one or many layer 2 interfaces have the wrong MTU configured. """ name = "VerifyL2MTU" description = "Verifies the global L2 MTU of all L2 interfaces." - categories = ["interfaces"] - commands = [AntaCommand(command="show interfaces")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")] + + class Input(AntaTest.Input): + """Input model for the VerifyL2MTU test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring mtu: int = 9214 - """Default MTU we should have configured on all non-excluded interfaces""" - ignored_interfaces: List[str] = ["Management", "Loopback", "Vxlan", "Tunnel"] - """A list of L2 interfaces to ignore""" - specific_mtu: List[Dict[str, int]] = [] + """Default MTU we should have configured on all non-excluded interfaces. Defaults to 9214.""" + ignored_interfaces: list[str] = Field(default=["Management", "Loopback", "Vxlan", "Tunnel"]) + """A list of L2 interfaces to ignore. Defaults to ["Management", "Loopback", "Vxlan", "Tunnel"]""" + specific_mtu: list[dict[str, int]] = Field(default=[]) """A list of dictionary of L2 interfaces with their specific MTU configured""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyL2MTU.""" # Parameter to save incorrect interface settings wrong_l2mtu_intf: list[dict[str, int]] = [] command_output = self.instance_commands[0].json_output @@ -514,43 +517,43 @@ def test(self) -> None: class VerifyInterfaceIPv4(AntaTest): - """ - Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses. + """Verifies if an interface is configured with a correct primary and list of optional secondary IPv4 addresses. Expected Results: - * success: The test will pass if an interface is configured with a correct primary and secondary IPv4 address. - * failure: The test will fail if an interface is not found or the primary and secondary IPv4 addresses do not match with the input. + * Success: The test will pass if an interface is configured with a correct primary and secondary IPv4 address. + * Failure: The test will fail if an interface is not found or the primary and secondary IPv4 addresses do not match with the input. """ name = "VerifyInterfaceIPv4" description = "Verifies the interface IPv4 addresses." - categories = ["interfaces"] - commands = [AntaTemplate(template="show ip interface {interface}")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip interface {interface}")] class Input(AntaTest.Input): - """Inputs for the VerifyInterfaceIPv4 test.""" + """Input model for the VerifyInterfaceIPv4 test.""" - interfaces: List[InterfaceDetail] - """list of interfaces to be tested""" + interfaces: list[InterfaceDetail] + """List of interfaces with their details.""" class InterfaceDetail(BaseModel): - """Detail of an interface""" + """Model for an interface detail.""" name: Interface - """Name of the interface""" + """Name of the interface.""" primary_ip: IPv4Network - """Primary IPv4 address with subnet on interface""" - secondary_ips: Optional[List[IPv4Network]] = None - """Optional list of secondary IPv4 addresses with subnet on interface""" + """Primary IPv4 address in CIDR notation.""" + secondary_ips: list[IPv4Network] | None = None + """Optional list of secondary IPv4 addresses in CIDR notation.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: - # Render the template for each interface + """Render the template for each interface in the input list.""" return [ template.render(interface=interface.name, primary_ip=interface.primary_ip, secondary_ips=interface.secondary_ips) for interface in self.inputs.interfaces ] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyInterfaceIPv4.""" self.result.is_success() for command in self.instance_commands: intf = command.params["interface"] @@ -595,27 +598,27 @@ def test(self) -> None: class VerifyIpVirtualRouterMac(AntaTest): - """ - Verifies the IP virtual router MAC address. + """Verifies the IP virtual router MAC address. Expected Results: - * success: The test will pass if the IP virtual router MAC address matches the input. - * failure: The test will fail if the IP virtual router MAC address does not match the input. + * Success: The test will pass if the IP virtual router MAC address matches the input. + * Failure: The test will fail if the IP virtual router MAC address does not match the input. """ name = "VerifyIpVirtualRouterMac" description = "Verifies the IP virtual router MAC address." - categories = ["interfaces"] - commands = [AntaCommand(command="show ip virtual-router")] + categories: ClassVar[list[str]] = ["interfaces"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip virtual-router")] class Input(AntaTest.Input): - """Inputs for the VerifyIpVirtualRouterMac test.""" + """Input model for the VerifyIpVirtualRouterMac test.""" mac_address: MacAddress - """IP virtual router MAC address""" + """IP virtual router MAC address.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyIpVirtualRouterMac.""" command_output = self.instance_commands[0].json_output["virtualMacs"] mac_address_found = get_item(command_output, "macAddress", self.inputs.mac_address) diff --git a/anta/tests/lanz.py b/anta/tests/lanz.py index 6b5a01521..515b33819 100644 --- a/anta/tests/lanz.py +++ b/anta/tests/lanz.py @@ -1,36 +1,39 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to LANZ -""" +"""Module related to LANZ tests.""" from __future__ import annotations +from typing import TYPE_CHECKING, ClassVar + from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyLANZ(AntaTest): - """ - Verifies if LANZ is enabled + """Verifies if LANZ (Latency Analyzer) is enabled. Expected results: - * success: the test will pass if lanz is enabled - * failure: the test will fail if lanz is disabled + * Success: The test will pass if LANZ is enabled. + * Failure: The test will fail if LANZ is disabled. """ name = "VerifyLANZ" description = "Verifies if LANZ is enabled." - categories = ["lanz"] - commands = [AntaCommand(command="show queue-monitor length status")] + categories: ClassVar[list[str]] = ["lanz"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show queue-monitor length status")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLANZ.""" command_output = self.instance_commands[0].json_output if command_output["lanzEnabled"] is not True: self.result.is_failure("LANZ is not enabled") else: - self.result.is_success("LANZ is enabled") + self.result.is_success() diff --git a/anta/tests/logging.py b/anta/tests/logging.py index ef5678681..9e612025c 100644 --- a/anta/tests/logging.py +++ b/anta/tests/logging.py @@ -1,57 +1,64 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the EOS various logging settings +"""Module related to the EOS various logging tests. -NOTE: 'show logging' does not support json output yet +NOTE: The EOS command `show logging` does not support JSON output format. """ + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -import logging import re from ipaddress import IPv4Address - -# Need to keep List for pydantic in python 3.8 -from typing import List +from typing import TYPE_CHECKING, ClassVar from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + import logging + + from anta.models import AntaTemplate + def _get_logging_states(logger: logging.Logger, command_output: str) -> str: - """ - Parse "show logging" output and gets operational logging states used - in the tests in this module. + """Parse `show logging` output and gets operational logging states used in the tests in this module. Args: - command_output: The 'show logging' output + ---- + logger: The logger object. + command_output: The `show logging` output. + + Returns: + ------- + str: The operational logging states. + """ log_states = command_output.partition("\n\nExternal configuration:")[0] - logger.debug(f"Device logging states:\n{log_states}") + logger.debug("Device logging states:\n%s", log_states) return log_states class VerifyLoggingPersistent(AntaTest): - """ - Verifies if logging persistent is enabled and logs are saved in flash. + """Verifies if logging persistent is enabled and logs are saved in flash. Expected Results: - * success: The test will pass if logging persistent is enabled and logs are in flash. - * failure: The test will fail if logging persistent is disabled or no logs are saved in flash. + * Success: The test will pass if logging persistent is enabled and logs are in flash. + * Failure: The test will fail if logging persistent is disabled or no logs are saved in flash. """ name = "VerifyLoggingPersistent" description = "Verifies if logging persistent is enabled and logs are saved in flash." - categories = ["logging"] - commands = [ + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show logging", ofmt="text"), AntaCommand(command="dir flash:/persist/messages", ofmt="text"), ] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingPersistent.""" self.result.is_success() log_output = self.instance_commands[0].text_output dir_flash_output = self.instance_commands[1].text_output @@ -65,27 +72,29 @@ def test(self) -> None: class VerifyLoggingSourceIntf(AntaTest): - """ - Verifies logging source-interface for a specified VRF. + """Verifies logging source-interface for a specified VRF. Expected Results: - * success: The test will pass if the provided logging source-interface is configured in the specified VRF. - * failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF. + * Success: The test will pass if the provided logging source-interface is configured in the specified VRF. + * Failure: The test will fail if the provided logging source-interface is NOT configured in the specified VRF. """ name = "VerifyLoggingSourceInt" description = "Verifies logging source-interface for a specified VRF." - categories = ["logging"] - commands = [AntaCommand(command="show logging", ofmt="text")] + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] + + class Input(AntaTest.Input): + """Input model for the VerifyLoggingSourceInt test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring interface: str - """Source-interface to use as source IP of log messages""" + """Source-interface to use as source IP of log messages.""" vrf: str = "default" - """The name of the VRF to transport log messages""" + """The name of the VRF to transport log messages. Defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingSourceInt.""" output = self.instance_commands[0].text_output pattern = rf"Logging source-interface '{self.inputs.interface}'.*VRF {self.inputs.vrf}" if re.search(pattern, _get_logging_states(self.logger, output)): @@ -95,31 +104,33 @@ def test(self) -> None: class VerifyLoggingHosts(AntaTest): - """ - Verifies logging hosts (syslog servers) for a specified VRF. + """Verifies logging hosts (syslog servers) for a specified VRF. Expected Results: - * success: The test will pass if the provided syslog servers are configured in the specified VRF. - * failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF. + * Success: The test will pass if the provided syslog servers are configured in the specified VRF. + * Failure: The test will fail if the provided syslog servers are NOT configured in the specified VRF. """ name = "VerifyLoggingHosts" description = "Verifies logging hosts (syslog servers) for a specified VRF." - categories = ["logging"] - commands = [AntaCommand(command="show logging", ofmt="text")] + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging", ofmt="text")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - hosts: List[IPv4Address] - """List of hosts (syslog servers) IP addresses""" + class Input(AntaTest.Input): + """Input model for the VerifyLoggingHosts test.""" + + hosts: list[IPv4Address] + """List of hosts (syslog servers) IP addresses.""" vrf: str = "default" - """The name of the VRF to transport log messages""" + """The name of the VRF to transport log messages. Defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingHosts.""" output = self.instance_commands[0].text_output not_configured = [] for host in self.inputs.hosts: - pattern = rf"Logging to '{str(host)}'.*VRF {self.inputs.vrf}" + pattern = rf"Logging to '{host!s}'.*VRF {self.inputs.vrf}" if not re.search(pattern, _get_logging_states(self.logger, output)): not_configured.append(str(host)) @@ -130,24 +141,24 @@ def test(self) -> None: class VerifyLoggingLogsGeneration(AntaTest): - """ - Verifies if logs are generated. + """Verifies if logs are generated. Expected Results: - * success: The test will pass if logs are generated. - * failure: The test will fail if logs are NOT generated. + * Success: The test will pass if logs are generated. + * Failure: The test will fail if logs are NOT generated. """ name = "VerifyLoggingLogsGeneration" description = "Verifies if logs are generated." - categories = ["logging"] - commands = [ + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="send log level informational message ANTA VerifyLoggingLogsGeneration validation"), AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), ] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingLogsGeneration.""" log_pattern = r"ANTA VerifyLoggingLogsGeneration validation" output = self.instance_commands[1].text_output lines = output.strip().split("\n")[::-1] @@ -159,18 +170,17 @@ def test(self) -> None: class VerifyLoggingHostname(AntaTest): - """ - Verifies if logs are generated with the device FQDN. + """Verifies if logs are generated with the device FQDN. Expected Results: - * success: The test will pass if logs are generated with the device FQDN. - * failure: The test will fail if logs are NOT generated with the device FQDN. + * Success: The test will pass if logs are generated with the device FQDN. + * Failure: The test will fail if logs are NOT generated with the device FQDN. """ name = "VerifyLoggingHostname" description = "Verifies if logs are generated with the device FQDN." - categories = ["logging"] - commands = [ + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="show hostname"), AntaCommand(command="send log level informational message ANTA VerifyLoggingHostname validation"), AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), @@ -178,6 +188,7 @@ class VerifyLoggingHostname(AntaTest): @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingHostname.""" output_hostname = self.instance_commands[0].json_output output_logging = self.instance_commands[2].text_output fqdn = output_hostname["fqdn"] @@ -195,24 +206,24 @@ def test(self) -> None: class VerifyLoggingTimestamp(AntaTest): - """ - Verifies if logs are generated with the approprate timestamp. + """Verifies if logs are generated with the approprate timestamp. Expected Results: - * success: The test will pass if logs are generated with the appropriated timestamp. - * failure: The test will fail if logs are NOT generated with the appropriated timestamp. + * Success: The test will pass if logs are generated with the appropriated timestamp. + * Failure: The test will fail if logs are NOT generated with the appropriated timestamp. """ name = "VerifyLoggingTimestamp" description = "Verifies if logs are generated with the appropriate timestamp." - categories = ["logging"] - commands = [ + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaCommand(command="send log level informational message ANTA VerifyLoggingTimestamp validation"), AntaCommand(command="show logging informational last 30 seconds | grep ANTA", ofmt="text", use_cache=False), ] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingTimestamp.""" log_pattern = r"ANTA VerifyLoggingTimestamp validation" timestamp_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}-\d{2}:\d{2}" output = self.instance_commands[1].text_output @@ -229,21 +240,21 @@ def test(self) -> None: class VerifyLoggingAccounting(AntaTest): - """ - Verifies if AAA accounting logs are generated. + """Verifies if AAA accounting logs are generated. Expected Results: - * success: The test will pass if AAA accounting logs are generated. - * failure: The test will fail if AAA accounting logs are NOT generated. + * Success: The test will pass if AAA accounting logs are generated. + * Failure: The test will fail if AAA accounting logs are NOT generated. """ name = "VerifyLoggingAccounting" description = "Verifies if AAA accounting logs are generated." - categories = ["logging"] - commands = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show aaa accounting logs | tail", ofmt="text")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyLoggingAccounting.""" pattern = r"cmd=show aaa accounting logs" output = self.instance_commands[0].text_output if re.search(pattern, output): @@ -253,24 +264,21 @@ def test(self) -> None: class VerifyLoggingErrors(AntaTest): - """ - This test verifies there are no syslog messages with a severity of ERRORS or higher. + """Verifies there are no syslog messages with a severity of ERRORS or higher. Expected Results: - * success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher. - * failure: The test will fail if ERRORS or higher syslog messages are present. + * Success: The test will pass if there are NO syslog messages with a severity of ERRORS or higher. + * Failure: The test will fail if ERRORS or higher syslog messages are present. """ - name = "VerifyLoggingWarning" - description = "This test verifies there are no syslog messages with a severity of ERRORS or higher." - categories = ["logging"] - commands = [AntaCommand(command="show logging threshold errors", ofmt="text")] + name = "VerifyLoggingErrors" + description = "Verifies there are no syslog messages with a severity of ERRORS or higher." + categories: ClassVar[list[str]] = ["logging"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show logging threshold errors", ofmt="text")] @AntaTest.anta_test def test(self) -> None: - """ - Run VerifyLoggingWarning validation - """ + """Main test function for VerifyLoggingErrors.""" command_output = self.instance_commands[0].text_output if len(command_output) == 0: diff --git a/anta/tests/mlag.py b/anta/tests/mlag.py index 2c2be0172..1d0229281 100644 --- a/anta/tests/mlag.py +++ b/anta/tests/mlag.py @@ -1,39 +1,41 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to Multi-chassis Link Aggregation (MLAG) -""" +"""Module related to Multi-chassis Link Aggregation (MLAG) tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -from pydantic import conint +from typing import TYPE_CHECKING, ClassVar -from anta.custom_types import MlagPriority +from anta.custom_types import MlagPriority, PositiveInteger from anta.models import AntaCommand, AntaTest from anta.tools.get_value import get_value +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyMlagStatus(AntaTest): - """ - This test verifies the health status of the MLAG configuration. + """Verifies the health status of the MLAG configuration. Expected Results: - * success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', + * Success: The test will pass if the MLAG state is 'active', negotiation status is 'connected', peer-link status and local interface status are 'up'. - * failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', + * Failure: The test will fail if the MLAG state is not 'active', negotiation status is not 'connected', peer-link status or local interface status are not 'up'. - * skipped: The test will be skipped if MLAG is 'disabled'. + * Skipped: The test will be skipped if MLAG is 'disabled'. """ name = "VerifyMlagStatus" description = "Verifies the health status of the MLAG configuration." - categories = ["mlag"] - commands = [AntaCommand(command="show mlag", ofmt="json")] + categories: ClassVar[list[str]] = ["mlag"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", ofmt="json")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMlagStatus.""" command_output = self.instance_commands[0].json_output if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") @@ -52,22 +54,22 @@ def test(self) -> None: class VerifyMlagInterfaces(AntaTest): - """ - This test verifies there are no inactive or active-partial MLAG ports. + """Verifies there are no inactive or active-partial MLAG ports. Expected Results: - * success: The test will pass if there are NO inactive or active-partial MLAG ports. - * failure: The test will fail if there are inactive or active-partial MLAG ports. - * skipped: The test will be skipped if MLAG is 'disabled'. + * Success: The test will pass if there are NO inactive or active-partial MLAG ports. + * Failure: The test will fail if there are inactive or active-partial MLAG ports. + * Skipped: The test will be skipped if MLAG is 'disabled'. """ name = "VerifyMlagInterfaces" description = "Verifies there are no inactive or active-partial MLAG ports." - categories = ["mlag"] - commands = [AntaCommand(command="show mlag", ofmt="json")] + categories: ClassVar[list[str]] = ["mlag"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", ofmt="json")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMlagInterfaces.""" command_output = self.instance_commands[0].json_output if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") @@ -79,23 +81,23 @@ def test(self) -> None: class VerifyMlagConfigSanity(AntaTest): - """ - This test verifies there are no MLAG config-sanity inconsistencies. + """Verifies there are no MLAG config-sanity inconsistencies. Expected Results: - * success: The test will pass if there are NO MLAG config-sanity inconsistencies. - * failure: The test will fail if there are MLAG config-sanity inconsistencies. - * skipped: The test will be skipped if MLAG is 'disabled'. - * error: The test will give an error if 'mlagActive' is not found in the JSON response. + * Success: The test will pass if there are NO MLAG config-sanity inconsistencies. + * Failure: The test will fail if there are MLAG config-sanity inconsistencies. + * Skipped: The test will be skipped if MLAG is 'disabled'. + * Error: The test will give an error if 'mlagActive' is not found in the JSON response. """ name = "VerifyMlagConfigSanity" description = "Verifies there are no MLAG config-sanity inconsistencies." - categories = ["mlag"] - commands = [AntaCommand(command="show mlag config-sanity", ofmt="json")] + categories: ClassVar[list[str]] = ["mlag"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag config-sanity", ofmt="json")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMlagConfigSanity.""" command_output = self.instance_commands[0].json_output if (mlag_status := get_value(command_output, "mlagActive")) is None: self.result.is_error(message="Incorrect JSON response - 'mlagActive' state was not found") @@ -112,28 +114,30 @@ def test(self) -> None: class VerifyMlagReloadDelay(AntaTest): - """ - This test verifies the reload-delay parameters of the MLAG configuration. + """Verifies the reload-delay parameters of the MLAG configuration. Expected Results: - * success: The test will pass if the reload-delay parameters are configured properly. - * failure: The test will fail if the reload-delay parameters are NOT configured properly. - * skipped: The test will be skipped if MLAG is 'disabled'. + * Success: The test will pass if the reload-delay parameters are configured properly. + * Failure: The test will fail if the reload-delay parameters are NOT configured properly. + * Skipped: The test will be skipped if MLAG is 'disabled'. """ name = "VerifyMlagReloadDelay" description = "Verifies the MLAG reload-delay parameters." - categories = ["mlag"] - commands = [AntaCommand(command="show mlag", ofmt="json")] + categories: ClassVar[list[str]] = ["mlag"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyMlagReloadDelay test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - reload_delay: conint(ge=0) # type: ignore - """Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled""" - reload_delay_non_mlag: conint(ge=0) # type: ignore - """Delay (seconds) after reboot until ports that are not part of an MLAG are enabled""" + reload_delay: PositiveInteger + """Delay (seconds) after reboot until non peer-link ports that are part of an MLAG are enabled.""" + reload_delay_non_mlag: PositiveInteger + """Delay (seconds) after reboot until ports that are not part of an MLAG are enabled.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMlagReloadDelay.""" command_output = self.instance_commands[0].json_output if command_output["state"] == "disabled": self.result.is_skipped("MLAG is disabled") @@ -148,32 +152,34 @@ def test(self) -> None: class VerifyMlagDualPrimary(AntaTest): - """ - This test verifies the dual-primary detection and its parameters of the MLAG configuration. + """Verifies the dual-primary detection and its parameters of the MLAG configuration. Expected Results: - * success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly. - * failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly. - * skipped: The test will be skipped if MLAG is 'disabled'. + * Success: The test will pass if the dual-primary detection is enabled and its parameters are configured properly. + * Failure: The test will fail if the dual-primary detection is NOT enabled or its parameters are NOT configured properly. + * Skipped: The test will be skipped if MLAG is 'disabled'. """ name = "VerifyMlagDualPrimary" description = "Verifies the MLAG dual-primary detection parameters." - categories = ["mlag"] - commands = [AntaCommand(command="show mlag detail", ofmt="json")] + categories: ClassVar[list[str]] = ["mlag"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyMlagDualPrimary test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - detection_delay: conint(ge=0) # type: ignore - """Delay detection (seconds)""" + detection_delay: PositiveInteger + """Delay detection (seconds).""" errdisabled: bool = False - """Errdisabled all interfaces when dual-primary is detected""" - recovery_delay: conint(ge=0) # type: ignore - """Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled""" - recovery_delay_non_mlag: conint(ge=0) # type: ignore - """Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled""" + """Errdisabled all interfaces when dual-primary is detected.""" + recovery_delay: PositiveInteger + """Delay (seconds) after dual-primary detection resolves until non peer-link ports that are part of an MLAG are enabled.""" + recovery_delay_non_mlag: PositiveInteger + """Delay (seconds) after dual-primary detection resolves until ports that are not part of an MLAG are enabled.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMlagDualPrimary.""" errdisabled_action = "errdisableAllInterfaces" if self.inputs.errdisabled else "none" command_output = self.instance_commands[0].json_output if command_output["state"] == "disabled": @@ -196,8 +202,7 @@ def test(self) -> None: class VerifyMlagPrimaryPriority(AntaTest): - """ - Test class to verify the MLAG (Multi-Chassis Link Aggregation) primary priority. + """Verify the MLAG (Multi-Chassis Link Aggregation) primary priority. Expected Results: * Success: The test will pass if the MLAG state is set as 'primary' and the priority matches the input. @@ -207,17 +212,18 @@ class VerifyMlagPrimaryPriority(AntaTest): name = "VerifyMlagPrimaryPriority" description = "Verifies the configuration of the MLAG primary priority." - categories = ["mlag"] - commands = [AntaCommand(command="show mlag detail")] + categories: ClassVar[list[str]] = ["mlag"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show mlag detail")] class Input(AntaTest.Input): - """Inputs for the VerifyMlagPrimaryPriority test.""" + """Input model for the VerifyMlagPrimaryPriority test.""" primary_priority: MlagPriority """The expected MLAG primary priority.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMlagPrimaryPriority.""" command_output = self.instance_commands[0].json_output self.result.is_success() # Skip the test if MLAG is disabled @@ -235,5 +241,5 @@ def test(self) -> None: # Check primary priority if primary_priority != self.inputs.primary_priority: self.result.is_failure( - f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead." + f"The primary priority does not match expected. Expected `{self.inputs.primary_priority}`, but found `{primary_priority}` instead.", ) diff --git a/anta/tests/multicast.py b/anta/tests/multicast.py index ecd6ec26f..e98104a18 100644 --- a/anta/tests/multicast.py +++ b/anta/tests/multicast.py @@ -1,36 +1,43 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to multicast -""" +"""Module related to multicast and IGMP tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -# Need to keep Dict for pydantic in python 3.8 -from typing import Dict +from typing import TYPE_CHECKING, ClassVar from anta.custom_types import Vlan from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyIGMPSnoopingVlans(AntaTest): - """ - Verifies the IGMP snooping configuration for some VLANs. + """Verifies the IGMP snooping status for the provided VLANs. + + Expected Results: + * Success: The test will pass if the IGMP snooping status matches the expected status for the provided VLANs. + * Failure: The test will fail if the IGMP snooping status does not match the expected status for the provided VLANs. """ name = "VerifyIGMPSnoopingVlans" - description = "Verifies the IGMP snooping configuration for some VLANs." - categories = ["multicast", "igmp"] - commands = [AntaCommand(command="show ip igmp snooping")] + description = "Verifies the IGMP snooping status for the provided VLANs." + categories: ClassVar[list[str]] = ["multicast"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - vlans: Dict[Vlan, bool] - """Dictionary of VLANs with associated IGMP configuration status (True=enabled, False=disabled)""" + class Input(AntaTest.Input): + """Input model for the VerifyIGMPSnoopingVlans test.""" + + vlans: dict[Vlan, bool] + """Dictionary with VLAN ID and whether IGMP snooping must be enabled (True) or disabled (False).""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyIGMPSnoopingVlans.""" command_output = self.instance_commands[0].json_output self.result.is_success() for vlan, enabled in self.inputs.vlans.items(): @@ -44,21 +51,27 @@ def test(self) -> None: class VerifyIGMPSnoopingGlobal(AntaTest): - """ - Verifies the IGMP snooping global configuration. + """Verifies the IGMP snooping global status. + + Expected Results: + * Success: The test will pass if the IGMP snooping global status matches the expected status. + * Failure: The test will fail if the IGMP snooping global status does not match the expected status. """ name = "VerifyIGMPSnoopingGlobal" description = "Verifies the IGMP snooping global configuration." - categories = ["multicast", "igmp"] - commands = [AntaCommand(command="show ip igmp snooping")] + categories: ClassVar[list[str]] = ["multicast"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip igmp snooping")] + + class Input(AntaTest.Input): + """Input model for the VerifyIGMPSnoopingGlobal test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring enabled: bool - """Expected global IGMP snooping configuration (True=enabled, False=disabled)""" + """Whether global IGMP snopping must be enabled (True) or disabled (False).""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyIGMPSnoopingGlobal.""" command_output = self.instance_commands[0].json_output self.result.is_success() igmp_state = command_output["igmpSnoopingState"] diff --git a/anta/tests/profiles.py b/anta/tests/profiles.py index a0ed6d776..827f5fac6 100644 --- a/anta/tests/profiles.py +++ b/anta/tests/profiles.py @@ -1,36 +1,44 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to ASIC profiles -""" +"""Module related to ASIC profile tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, ClassVar, Literal from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyUnifiedForwardingTableMode(AntaTest): - """ - Verifies the device is using the expected Unified Forwarding Table mode. + """Verifies the device is using the expected UFT (Unified Forwarding Table) mode. + + Expected Results: + * Success: The test will pass if the device is using the expected UFT mode. + * Failure: The test will fail if the device is not using the expected UFT mode. """ name = "VerifyUnifiedForwardingTableMode" - description = "" - categories = ["profiles"] - commands = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")] + description = "Verifies the device is using the expected UFT mode." + categories: ClassVar[list[str]] = ["profiles"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show platform trident forwarding-table partition", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyUnifiedForwardingTableMode test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring mode: Literal[0, 1, 2, 3, 4, "flexible"] - """Expected UFT mode""" + """Expected UFT mode. Valid values are 0, 1, 2, 3, 4, or "flexible".""" @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyUnifiedForwardingTableMode.""" command_output = self.instance_commands[0].json_output if command_output["uftMode"] == str(self.inputs.mode): self.result.is_success() @@ -39,22 +47,28 @@ def test(self) -> None: class VerifyTcamProfile(AntaTest): - """ - Verifies the device is using the configured TCAM profile. + """Verifies that the device is using the provided Ternary Content-Addressable Memory (TCAM) profile. + + Expected Results: + * Success: The test will pass if the provided TCAM profile is actually running on the device. + * Failure: The test will fail if the provided TCAM profile is not running on the device. """ name = "VerifyTcamProfile" - description = "Verify that the assigned TCAM profile is actually running on the device" - categories = ["profiles"] - commands = [AntaCommand(command="show hardware tcam profile", ofmt="json")] + description = "Verifies the device TCAM profile." + categories: ClassVar[list[str]] = ["profiles"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hardware tcam profile", ofmt="json")] + + class Input(AntaTest.Input): + """Input model for the VerifyTcamProfile test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring profile: str - """Expected TCAM profile""" + """Expected TCAM profile.""" @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTcamProfile.""" command_output = self.instance_commands[0].json_output if command_output["pmfProfiles"]["FixedSystem"]["status"] == command_output["pmfProfiles"]["FixedSystem"]["config"] == self.inputs.profile: self.result.is_success() diff --git a/anta/tests/ptp.py b/anta/tests/ptp.py index 1d7657960..1f354cbf0 100644 --- a/anta/tests/ptp.py +++ b/anta/tests/ptp.py @@ -1,172 +1,194 @@ # Copyright (c) 2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to PTP tests -""" +"""Module related to PTP tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + from anta.decorators import skip_on_platforms from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate -class PtpModeStatus(AntaTest): - """ - This test verifies that the device is in Boundary Clock Mode - """ - name = "PtpModeStatus" - description = "Check Boundary Clock mode is enabled" - categories = ["ptp"] - commands = [AntaCommand(command="show ptp", ofmt="json")] +class VerifyPtpModeStatus(AntaTest): + """Verifies that the device is configured as a Precision Time Protocol (PTP) Boundary Clock (BC). + + Expected Results: + * Success: The test will pass if the device is a BC. + * Failure: The test will fail if the device is not a BC. + * Error: The test will error if the 'ptpMode' variable is not present in the command output. + """ - # Verify that all switches are running Boundary Clock + name = "VerifyPtpModeStatus" + description = "Verifies that the device is configured as a PTP Boundary Clock." + categories: ClassVar[list[str]] = ["ptp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyPtpModeStatus.""" command_output = self.instance_commands[0].json_output - try: - ptp_mode = command_output["ptpMode"] - except KeyError: - self.result.is_error("ptpMode variable is not present in the command output") + + if (ptp_mode := command_output.get("ptpMode")) is None: + self.result.is_error("'ptpMode' variable is not present in the command output") return - if ptp_mode == "ptpBoundaryClock": - self.result.is_success(f"Valid PTP mode found: '{ptp_mode}'") + if ptp_mode != "ptpBoundaryClock": + self.result.is_failure(f"The device is not configured as a PTP Boundary Clock: '{ptp_mode}'") else: - self.result.is_failure(f"Device is not configured as a Boundary Clock: '{ptp_mode}'") + self.result.is_success() -class PtpGMStatus(AntaTest): - """ - This test verifies that the device is locked to a valid GM - The user should provide a single "validGM" as an input - To test PTP failover, re-run the test with secondary GMid configured. +class VerifyPtpGMStatus(AntaTest): + """Verifies that the device is locked to a valid Precision Time Protocol (PTP) Grandmaster (GM). + + To test PTP failover, re-run the test with a secondary GMID configured. + + Expected Results: + * Success: The test will pass if the device is locked to the provided Grandmaster. + * Failure: The test will fail if the device is not locked to the provided Grandmaster. + * Error: The test will error if the 'gmClockIdentity' variable is not present in the command output. """ - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - validGM: str - """ validGM is the identity of the grandmaster clock""" + class Input(AntaTest.Input): + """Input model for the VerifyPtpGMStatus test.""" - name = "PtpGMStatus" - description = "Check device is locked to an allowed GM" - categories = ["ptp"] - commands = [AntaCommand(command="show ptp", ofmt="json")] + gmid: str + """Identifier of the Grandmaster to which the device should be locked.""" - # Verify that all switches are locked to the same GMID, and that this GMID is one of the provided GMs + name = "VerifyPtpGMStatus" + description = "Verifies that the device is locked to a valid PTP Grandmaster." + categories: ClassVar[list[str]] = ["ptp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyPtpGMStatus.""" command_output = self.instance_commands[0].json_output - validGM = self.inputs.validGM - try: - ptp_gmid = command_output["ptpClockSummary"]["gmClockIdentity"] - except KeyError: - self.result.is_error("gmClockIdentity variable is not present in the command output") + + if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None: + self.result.is_error("'ptpClockSummary' variable is not present in the command output") return - if ptp_gmid == validGM: - self.result.is_success(f"Valid GM found: '{ptp_gmid}'") + if ptp_clock_summary["gmClockIdentity"] != self.inputs.gmid: + self.result.is_failure( + f"The device is locked to the following Grandmaster: '{ptp_clock_summary['gmClockIdentity']}', which differ from the expected one.", + ) else: - self.result.is_failure(f"Device is not locked to valid GM: '{ptp_gmid}'") + self.result.is_success() -class PtpLockStatus(AntaTest): - """ - This test verifies that the device as a recent PTP lock - """ +class VerifyPtpLockStatus(AntaTest): + """Verifies that the device was locked to the upstream Precision Time Protocol (PTP) Grandmaster (GM) in the last minute. - name = "PtpLockStatus" - description = "Check that the device was locked to the upstream GM in the last minute" - categories = ["ptp"] - commands = [AntaCommand(command="show ptp", ofmt="json")] + Expected Results: + * Success: The test will pass if the device was locked to the upstream GM in the last minute. + * Failure: The test will fail if the device was not locked to the upstream GM in the last minute. + * Error: The test will error if the 'lastSyncTime' variable is not present in the command output. + """ - # Verify that last lock time is within the last minute + name = "VerifyPtpLockStatus" + description = "Verifies that the device was locked to the upstream PTP GM in the last minute." + categories: ClassVar[list[str]] = ["ptp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyPtpLockStatus.""" + threshold = 60 command_output = self.instance_commands[0].json_output - try: - ptp_lastSyncTime = command_output["ptpClockSummary"]["lastSyncTime"] - except KeyError: - self.result.is_error("lastSyncTime variable is not present in the command output") - return - ptp_currentPtpSystemTime = ( - command_output["ptpClockSummary"]["currentPtpSystemTime"] if "currentPtpSystemTime" in command_output["ptpClockSummary"].keys() else "" - ) + if (ptp_clock_summary := command_output.get("ptpClockSummary")) is None: + self.result.is_error("'ptpClockSummary' variable is not present in the command output") + return - time_to_last_sync = ptp_currentPtpSystemTime - ptp_lastSyncTime + time_difference = ptp_clock_summary["currentPtpSystemTime"] - ptp_clock_summary["lastSyncTime"] - if time_to_last_sync <= 60: - self.result.is_success(f"Current PTP lock found: '{time_to_last_sync}'s") + if time_difference >= threshold: + self.result.is_failure(f"The device lock is more than {threshold}s old: {time_difference}s") else: - self.result.is_failure(f"Device Lock is old: '{time_to_last_sync}'s") + self.result.is_success() -class PtpOffset(AntaTest): - """ - This test verifies that the has a reasonable offset from master (jitter) level - """ +class VerifyPtpOffset(AntaTest): + """Verifies that the Precision Time Protocol (PTP) timing offset is within +/- 1000ns from the master clock. - name = "PtpOffset" - description = "Check that the Offset From Master is within +/- 1000ns" - categories = ["ptp"] - commands = [AntaCommand(command="show ptp monitor", ofmt="json")] + Expected Results: + * Success: The test will pass if the PTP timing offset is within +/- 1000ns from the master clock. + * Failure: The test will fail if the PTP timing offset is greater than +/- 1000ns from the master clock. + * Skipped: The test will be skipped if PTP is not configured. + """ - # Verify that offset from master is acceptable + name = "VerifyPtpOffset" + description = "Verifies that the PTP timing offset is within +/- 1000ns from the master clock." + categories: ClassVar[list[str]] = ["ptp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp monitor", ofmt="json")] @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyPtpOffset.""" + threshold = 1000 + offset_interfaces: dict[str, list[int]] = {} command_output = self.instance_commands[0].json_output - offsetMaxPos = 0 - offsetMaxNeg = 0 + + if not command_output["ptpMonitorData"]: + self.result.is_skipped("PTP is not configured") + return for interface in command_output["ptpMonitorData"]: - if interface["offsetFromMaster"] > offsetMaxPos: - offsetMaxPos = interface["offsetFromMaster"] - elif interface["offsetFromMaster"] < offsetMaxNeg: - offsetMaxNeg = interface["offsetFromMaster"] + if abs(interface["offsetFromMaster"]) > threshold: + offset_interfaces.setdefault(interface["intf"], []).append(interface["offsetFromMaster"]) - if (offsetMaxPos < 1000) and (offsetMaxNeg > -1000): - self.result.is_success(f"Max Offset From Master (Max/Min): '{offsetMaxPos, offsetMaxNeg}'s") + if offset_interfaces: + self.result.is_failure(f"The device timing offset from master is greater than +/- {threshold}ns: {offset_interfaces}") else: - self.result.is_failure(f"Bad max Offset From Master (Max/Min): '{offsetMaxPos, offsetMaxNeg}'") + self.result.is_success() -class PtpPortModeStatus(AntaTest): - """ - This test verifies that all ports are in stable PTP modes +class VerifyPtpPortModeStatus(AntaTest): + """Verifies that all interfaces are in a valid Precision Time Protocol (PTP) state. + + The interfaces can be in one of the following state: Master, Slave, Passive, or Disabled. + + Expected Results: + * Success: The test will pass if all PTP enabled interfaces are in a valid state. + * Failure: The test will fail if there are no PTP enabled interfaces or if some interfaces are not in a valid state. """ - name = "PtpPortModeStatus" - description = "Check that all PTP enabled ports are not in transitory states" - categories = ["ptp"] - commands = [AntaCommand(command="show ptp", ofmt="json")] + name = "VerifyPtpPortModeStatus" + description = "Verifies the PTP interfaces state." + categories: ClassVar[list[str]] = ["ptp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ptp", ofmt="json")] - # Verify that ports are either Master / Slave / Passive or Disabled @skip_on_platforms(["cEOSLab", "vEOS-lab"]) @AntaTest.anta_test def test(self) -> None: - validPortModes = ["psMaster", "psSlave", "psPassive", "psDisabled"] + """Main test function for VerifyPtpPortModeStatus.""" + valid_state = ("psMaster", "psSlave", "psPassive", "psDisabled") command_output = self.instance_commands[0].json_output - invalid_ports_found = False # Initialize a boolean variable to track if any invalid ports are found - - for interface in command_output["ptpIntfSummaries"]: - for vlan in command_output["ptpIntfSummaries"][interface]["ptpIntfVlanSummaries"]: - if vlan["portState"] not in validPortModes: - invalid_ports_found = True # Set the boolean variable to True if an invalid port is found - break # Exit the inner loop if an invalid port is found + if not command_output["ptpIntfSummaries"]: + self.result.is_failure("No interfaces are PTP enabled") + return - if invalid_ports_found: - break # Exit the outer loop if an invalid port is found + invalid_interfaces = [ + interface + for interface in command_output["ptpIntfSummaries"] + for vlan in command_output["ptpIntfSummaries"][interface]["ptpIntfVlanSummaries"] + if vlan["portState"] not in valid_state + ] - if not invalid_ports_found: - self.result.is_success("Ports all in valid state") + if not invalid_interfaces: + self.result.is_success() else: - self.result.is_failure("Some ports are not in valid states (Master / Slave / Passive / Disabled)") + self.result.is_failure(f"The following interface(s) are not in a valid PTP state: '{invalid_interfaces}'") diff --git a/anta/tests/routing/__init__.py b/anta/tests/routing/__init__.py index e772bee41..d4b378697 100644 --- a/anta/tests/routing/__init__.py +++ b/anta/tests/routing/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Package related to routing tests.""" diff --git a/anta/tests/routing/bgp.py b/anta/tests/routing/bgp.py index c93577fee..32bf6395a 100644 --- a/anta/tests/routing/bgp.py +++ b/anta/tests/routing/bgp.py @@ -1,15 +1,14 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -BGP test functions -""" +"""Module related to BGP tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations from ipaddress import IPv4Address, IPv4Network, IPv6Address -from typing import Any, List, Optional, Union, cast +from typing import Any, ClassVar, List, Optional, Union, cast from pydantic import BaseModel, Field, PositiveInt, model_validator from pydantic.v1.utils import deep_update @@ -20,22 +19,22 @@ from anta.tools.get_item import get_item from anta.tools.get_value import get_value -# Need to keep List for pydantic in python 3.8 - -def _add_bgp_failures(failures: dict[tuple[str, Union[str, None]], dict[str, Any]], afi: Afi, safi: Optional[Safi], vrf: str, issue: Any) -> None: - """ - Add a BGP failure entry to the given `failures` dictionary. +def _add_bgp_failures(failures: dict[tuple[str, str | None], dict[str, Any]], afi: Afi, safi: Safi | None, vrf: str, issue: str | dict[str, Any]) -> None: + """Add a BGP failure entry to the given `failures` dictionary. Note: This function modifies `failures` in-place. - Parameters: + Args: + ---- failures (dict): The dictionary to which the failure will be added. afi (Afi): The address family identifier. vrf (str): The VRF name. safi (Safi, optional): The subsequent address family identifier. issue (Any): A description of the issue. Can be of any type. + Example: + ------- The `failures` dictionnary will have the following structure: { ('afi1', 'safi1'): { @@ -53,40 +52,43 @@ def _add_bgp_failures(failures: dict[tuple[str, Union[str, None]], dict[str, Any } } } + """ key = (afi, safi) - if safi: - failure_entry = failures.setdefault(key, {"afi": afi, "safi": safi, "vrfs": {}}) - else: - failure_entry = failures.setdefault(key, {"afi": afi, "vrfs": {}}) + failure_entry = failures.setdefault(key, {"afi": afi, "safi": safi, "vrfs": {}}) if safi else failures.setdefault(key, {"afi": afi, "vrfs": {}}) failure_entry["vrfs"][vrf] = issue -def _check_peer_issues(peer_data: Optional[dict[str, Any]]) -> dict[str, Any]: - """ - Check for issues in BGP peer data. +def _check_peer_issues(peer_data: dict[str, Any] | None) -> dict[str, Any]: + """Check for issues in BGP peer data. - Parameters: + Args: + ---- peer_data (dict, optional): The BGP peer data dictionary nested in the `show bgp summary` command. Returns: + ------- dict: Dictionary with keys indicating issues or an empty dictionary if no issues. + Raises: + ------ + ValueError: If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. + Example: + ------- {"peerNotFound": True} {"peerState": "Idle", "inMsgQueue": 2, "outMsgQueue": 0} {} - Raises: - ValueError: If any of the required keys ("peerState", "inMsgQueue", "outMsgQueue") are missing in `peer_data`, i.e. invalid BGP peer data. """ if peer_data is None: return {"peerNotFound": True} if any(key not in peer_data for key in ["peerState", "inMsgQueue", "outMsgQueue"]): - raise ValueError("Provided BGP peer data is invalid.") + msg = "Provided BGP peer data is invalid." + raise ValueError(msg) if peer_data["peerState"] != "Established" or peer_data["inMsgQueue"] != 0 or peer_data["outMsgQueue"] != 0: return {"peerState": peer_data["peerState"], "inMsgQueue": peer_data["inMsgQueue"], "outMsgQueue": peer_data["outMsgQueue"]} @@ -95,15 +97,20 @@ def _check_peer_issues(peer_data: Optional[dict[str, Any]]) -> dict[str, Any]: def _add_bgp_routes_failure( - bgp_routes: list[str], bgp_output: dict[str, Any], peer: str, vrf: str, route_type: str = "advertised_routes" + bgp_routes: list[str], + bgp_output: dict[str, Any], + peer: str, + vrf: str, + route_type: str = "advertised_routes", ) -> dict[str, dict[str, dict[str, dict[str, list[str]]]]]: - """ - Identifies missing BGP routes and invalid or inactive route entries. + """Identify missing BGP routes and invalid or inactive route entries. This function checks the BGP output from the device against the expected routes. + It identifies any missing routes as well as any routes that are invalid or inactive. The results are returned in a dictionary. - Parameters: + Args: + ---- bgp_routes (list[str]): The list of expected routes. bgp_output (dict[str, Any]): The BGP output from the device. peer (str): The IP address of the BGP peer. @@ -111,66 +118,69 @@ def _add_bgp_routes_failure( route_type (str, optional): The type of BGP routes. Defaults to 'advertised_routes'. Returns: + ------- dict[str, dict[str, dict[str, dict[str, list[str]]]]]: A dictionary containing the missing routes and invalid or inactive routes. - """ + """ # Prepare the failure routes dictionary failure_routes: dict[str, dict[str, Any]] = {} # Iterate over the expected BGP routes for route in bgp_routes: - route = str(route) - failure = {"bgp_peers": {peer: {vrf: {route_type: {route: Any}}}}} + str_route = str(route) + failure = {"bgp_peers": {peer: {vrf: {route_type: {str_route: Any}}}}} # Check if the route is missing in the BGP output - if route not in bgp_output: + if str_route not in bgp_output: # If missing, add it to the failure routes dictionary - failure["bgp_peers"][peer][vrf][route_type][route] = "Not found" + failure["bgp_peers"][peer][vrf][route_type][str_route] = "Not found" failure_routes = deep_update(failure_routes, failure) continue # Check if the route is active and valid - is_active = bgp_output[route]["bgpRoutePaths"][0]["routeType"]["valid"] - is_valid = bgp_output[route]["bgpRoutePaths"][0]["routeType"]["active"] + is_active = bgp_output[str_route]["bgpRoutePaths"][0]["routeType"]["valid"] + is_valid = bgp_output[str_route]["bgpRoutePaths"][0]["routeType"]["active"] # If the route is either inactive or invalid, add it to the failure routes dictionary if not is_active or not is_valid: - failure["bgp_peers"][peer][vrf][route_type][route] = {"valid": is_valid, "active": is_active} + failure["bgp_peers"][peer][vrf][route_type][str_route] = {"valid": is_valid, "active": is_active} failure_routes = deep_update(failure_routes, failure) return failure_routes class VerifyBGPPeerCount(AntaTest): - """ - This test verifies the count of BGP peers for a given address family. + """Verifies the count of BGP peers for a given address family. It supports multiple types of address families (AFI) and subsequent service families (SAFI). + Please refer to the Input class attributes below for details. Expected Results: - * success: If the count of BGP peers matches the expected count for each address family and VRF. - * failure: If the count of BGP peers does not match the expected count, or if BGP is not configured for an expected VRF or address family. + * Success: If the count of BGP peers matches the expected count for each address family and VRF. + * Failure: If the count of BGP peers does not match the expected count, or if BGP is not configured for an expected VRF or address family. """ name = "VerifyBGPPeerCount" description = "Verifies the count of BGP peers." - categories = ["bgp"] - commands = [ + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}"), AntaTemplate(template="show bgp {afi} summary"), ] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - address_families: List[BgpAfi] - """ - List of BGP address families (BgpAfi) - """ + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeerCount test.""" + + address_families: list[BgpAfi] + """List of BGP address families (BgpAfi).""" + + class BgpAfi(BaseModel): + """Model for a BGP address family (AFI) and subsequent service family (SAFI).""" - class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring afi: Afi - """BGP address family (AFI)""" - safi: Optional[Safi] = None + """BGP address family (AFI).""" + safi: Safi | None = None """Optional BGP subsequent service family (SAFI). If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. @@ -182,12 +192,11 @@ class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. """ num_peers: PositiveInt - """Number of expected BGP peer(s)""" + """Number of expected BGP peer(s).""" @model_validator(mode="after") def validate_inputs(self: BaseModel) -> BaseModel: - """ - Validate the inputs provided to the BgpAfi class. + """Validate the inputs provided to the BgpAfi class. If afi is either ipv4 or ipv6, safi must be provided. @@ -195,14 +204,18 @@ def validate_inputs(self: BaseModel) -> BaseModel: """ if self.afi in ["ipv4", "ipv6"]: if self.safi is None: - raise ValueError("'safi' must be provided when afi is ipv4 or ipv6") + msg = "'safi' must be provided when afi is ipv4 or ipv6" + raise ValueError(msg) elif self.safi is not None: - raise ValueError("'safi' must not be provided when afi is not ipv4 or ipv6") + msg = "'safi' must not be provided when afi is not ipv4 or ipv6" + raise ValueError(msg) elif self.vrf != "default": - raise ValueError("'vrf' must be default when afi is not ipv4 or ipv6") + msg = "'vrf' must be default when afi is not ipv4 or ipv6" + raise ValueError(msg) return self def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP address family in the input list.""" commands = [] for afi in self.inputs.address_families: if template == VerifyBGPPeerCount.commands[0] and afi.afi in ["ipv4", "ipv6"]: @@ -213,6 +226,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPPeerCount.""" self.result.is_success() failures: dict[tuple[str, Any], dict[str, Any]] = {} @@ -244,37 +258,39 @@ def test(self) -> None: class VerifyBGPPeersHealth(AntaTest): - """ - This test verifies the health of BGP peers. + """Verifies the health of BGP peers. It will validate that all BGP sessions are established and all message queues for these BGP sessions are empty for a given address family. It supports multiple types of address families (AFI) and subsequent service families (SAFI). + Please refer to the Input class attributes below for details. Expected Results: - * success: If all BGP sessions are established and all messages queues are empty for each address family and VRF. - * failure: If there are issues with any of the BGP sessions, or if BGP is not configured for an expected VRF or address family. + * Success: If all BGP sessions are established and all messages queues are empty for each address family and VRF. + * Failure: If there are issues with any of the BGP sessions, or if BGP is not configured for an expected VRF or address family. """ name = "VerifyBGPPeersHealth" description = "Verifies the health of BGP peers" - categories = ["bgp"] - commands = [ + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}"), AntaTemplate(template="show bgp {afi} summary"), ] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - address_families: List[BgpAfi] - """ - List of BGP address families (BgpAfi) - """ + class Input(AntaTest.Input): + """Input model for the VerifyBGPPeersHealth test.""" + + address_families: list[BgpAfi] + """List of BGP address families (BgpAfi).""" + + class BgpAfi(BaseModel): + """Model for a BGP address family (AFI) and subsequent service family (SAFI).""" - class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring afi: Afi - """BGP address family (AFI)""" - safi: Optional[Safi] = None + """BGP address family (AFI).""" + safi: Safi | None = None """Optional BGP subsequent service family (SAFI). If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. @@ -288,8 +304,7 @@ class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring @model_validator(mode="after") def validate_inputs(self: BaseModel) -> BaseModel: - """ - Validate the inputs provided to the BgpAfi class. + """Validate the inputs provided to the BgpAfi class. If afi is either ipv4 or ipv6, safi must be provided. @@ -297,14 +312,18 @@ def validate_inputs(self: BaseModel) -> BaseModel: """ if self.afi in ["ipv4", "ipv6"]: if self.safi is None: - raise ValueError("'safi' must be provided when afi is ipv4 or ipv6") + msg = "'safi' must be provided when afi is ipv4 or ipv6" + raise ValueError(msg) elif self.safi is not None: - raise ValueError("'safi' must not be provided when afi is not ipv4 or ipv6") + msg = "'safi' must not be provided when afi is not ipv4 or ipv6" + raise ValueError(msg) elif self.vrf != "default": - raise ValueError("'vrf' must be default when afi is not ipv4 or ipv6") + msg = "'vrf' must be default when afi is not ipv4 or ipv6" + raise ValueError(msg) return self def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP address family in the input list.""" commands = [] for afi in self.inputs.address_families: if template == VerifyBGPPeersHealth.commands[0] and afi.afi in ["ipv4", "ipv6"]: @@ -315,6 +334,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPPeersHealth.""" self.result.is_success() failures: dict[tuple[str, Any], dict[str, Any]] = {} @@ -350,37 +370,39 @@ def test(self) -> None: class VerifyBGPSpecificPeers(AntaTest): - """ - This test verifies the health of specific BGP peer(s). + """Verifies the health of specific BGP peer(s). It will validate that the BGP session is established and all message queues for this BGP session are empty for the given peer(s). It supports multiple types of address families (AFI) and subsequent service families (SAFI). + Please refer to the Input class attributes below for details. Expected Results: - * success: If the BGP session is established and all messages queues are empty for each given peer. - * failure: If the BGP session has issues or is not configured, or if BGP is not configured for an expected VRF or address family. + * Success: If the BGP session is established and all messages queues are empty for each given peer. + * Failure: If the BGP session has issues or is not configured, or if BGP is not configured for an expected VRF or address family. """ name = "VerifyBGPSpecificPeers" description = "Verifies the health of specific BGP peer(s)." - categories = ["bgp"] - commands = [ + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show bgp {afi} {safi} summary vrf {vrf}"), AntaTemplate(template="show bgp {afi} summary"), ] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - address_families: List[BgpAfi] - """ - List of BGP address families (BgpAfi) - """ + class Input(AntaTest.Input): + """Input model for the VerifyBGPSpecificPeers test.""" + + address_families: list[BgpAfi] + """List of BGP address families (BgpAfi).""" + + class BgpAfi(BaseModel): + """Model for a BGP address family (AFI) and subsequent service family (SAFI).""" - class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring afi: Afi - """BGP address family (AFI)""" - safi: Optional[Safi] = None + """BGP address family (AFI).""" + safi: Safi | None = None """Optional BGP subsequent service family (SAFI). If the input `afi` is `ipv4` or `ipv6`, a valid `safi` must be provided. @@ -393,13 +415,12 @@ class BgpAfi(BaseModel): # pylint: disable=missing-class-docstring If the input `afi` is not `ipv4` or `ipv6`, e.g. `evpn`, `vrf` must be `default`. """ - peers: List[Union[IPv4Address, IPv6Address]] - """List of BGP IPv4 or IPv6 peer""" + peers: list[IPv4Address | IPv6Address] + """List of BGP IPv4 or IPv6 peer.""" @model_validator(mode="after") def validate_inputs(self: BaseModel) -> BaseModel: - """ - Validate the inputs provided to the BgpAfi class. + """Validate the inputs provided to the BgpAfi class. If afi is either ipv4 or ipv6, safi must be provided and vrf must NOT be all. @@ -407,16 +428,21 @@ def validate_inputs(self: BaseModel) -> BaseModel: """ if self.afi in ["ipv4", "ipv6"]: if self.safi is None: - raise ValueError("'safi' must be provided when afi is ipv4 or ipv6") + msg = "'safi' must be provided when afi is ipv4 or ipv6" + raise ValueError(msg) if self.vrf == "all": - raise ValueError("'all' is not supported in this test. Use VerifyBGPPeersHealth test instead.") + msg = "'all' is not supported in this test. Use VerifyBGPPeersHealth test instead." + raise ValueError(msg) elif self.safi is not None: - raise ValueError("'safi' must not be provided when afi is not ipv4 or ipv6") + msg = "'safi' must not be provided when afi is not ipv4 or ipv6" + raise ValueError(msg) elif self.vrf != "default": - raise ValueError("'vrf' must be default when afi is not ipv4 or ipv6") + msg = "'vrf' must be default when afi is not ipv4 or ipv6" + raise ValueError(msg) return self def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each BGP address family in the input list.""" commands = [] for afi in self.inputs.address_families: if template == VerifyBGPSpecificPeers.commands[0] and afi.afi in ["ipv4", "ipv6"]: @@ -427,6 +453,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPSpecificPeers.""" self.result.is_success() failures: dict[tuple[str, Any], dict[str, Any]] = {} @@ -459,48 +486,43 @@ def test(self) -> None: class VerifyBGPExchangedRoutes(AntaTest): - """ - Verifies if the BGP peers have correctly advertised and received routes. + """Verifies if the BGP peers have correctly advertised and received routes. + The route type should be 'valid' and 'active' for a specified VRF. Expected results: - * success: If the BGP peers have correctly advertised and received routes of type 'valid' and 'active' for a specified VRF. - * failure: If a BGP peer is not found, the expected advertised/received routes are not found, or the routes are not 'valid' or 'active'. + * Success: If the BGP peers have correctly advertised and received routes of type 'valid' and 'active' for a specified VRF. + * Failure: If a BGP peer is not found, the expected advertised/received routes are not found, or the routes are not 'valid' or 'active'. """ name = "VerifyBGPExchangedRoutes" - description = "Verifies if BGP peers have correctly advertised/received routes with type as valid and active for a specified VRF." - categories = ["bgp"] - commands = [ + description = "Verifies the advertised and received routes of BGP peers." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ AntaTemplate(template="show bgp neighbors {peer} advertised-routes vrf {vrf}"), AntaTemplate(template="show bgp neighbors {peer} routes vrf {vrf}"), ] class Input(AntaTest.Input): - """ - Input parameters of the testcase. - """ + """Input model for the VerifyBGPExchangedRoutes test.""" - bgp_peers: List[BgpNeighbors] - """List of BGP peers""" + bgp_peers: list[BgpNeighbor] + """List of BGP neighbors.""" - class BgpNeighbors(BaseModel): - """ - This class defines the details of a BGP peer. - """ + class BgpNeighbor(BaseModel): + """Model for a BGP neighbor.""" peer_address: IPv4Address - """IPv4 address of a BGP peer""" + """IPv4 address of a BGP peer.""" vrf: str = "default" """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" - advertised_routes: List[IPv4Network] - """List of advertised routes of a BGP peer.""" - received_routes: List[IPv4Network] - """List of received routes of a BGP peer.""" + advertised_routes: list[IPv4Network] + """List of advertised routes in CIDR format.""" + received_routes: list[IPv4Network] + """List of received routes in CIDR format.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: - """Renders the template with the provided inputs. Returns a list of commands to be executed.""" - + """Render the template for each BGP neighbor in the input list.""" return [ template.render(peer=bgp_peer.peer_address, vrf=bgp_peer.vrf, advertised_routes=bgp_peer.advertised_routes, received_routes=bgp_peer.received_routes) for bgp_peer in self.inputs.bgp_peers @@ -508,6 +530,7 @@ def render(self, template: AntaTemplate) -> list[AntaCommand]: @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPExchangedRoutes.""" failures: dict[str, dict[str, Any]] = {"bgp_peers": {}} # Iterating over command output for different peers @@ -540,40 +563,37 @@ def test(self) -> None: class VerifyBGPPeerMPCaps(AntaTest): - """ - Verifies the multiprotocol capabilities of a BGP peer in a specified VRF. + """Verifies the multiprotocol capabilities of a BGP peer in a specified VRF. + Expected results: - * success: The test will pass if the BGP peer's multiprotocol capabilities are advertised, received, and enabled in the specified VRF. - * failure: The test will fail if BGP peers are not found or multiprotocol capabilities are not advertised, received, and enabled in the specified VRF. + * Success: The test will pass if the BGP peer's multiprotocol capabilities are advertised, received, and enabled in the specified VRF. + * Failure: The test will fail if BGP peers are not found or multiprotocol capabilities are not advertised, received, and enabled in the specified VRF. """ name = "VerifyBGPPeerMPCaps" - description = "Verifies the multiprotocol capabilities of a BGP peer in a specified VRF" - categories = ["bgp"] - commands = [AntaCommand(command="show bgp neighbors vrf all")] + description = "Verifies the multiprotocol capabilities of a BGP peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all")] class Input(AntaTest.Input): - """ - Input parameters of the testcase. - """ + """Input model for the VerifyBGPPeerMPCaps test.""" - bgp_peers: List[BgpPeers] + bgp_peers: list[BgpPeer] """List of BGP peers""" - class BgpPeers(BaseModel): - """ - This class defines the details of a BGP peer. - """ + class BgpPeer(BaseModel): + """Model for a BGP peer.""" peer_address: IPv4Address - """IPv4 address of a BGP peer""" + """IPv4 address of a BGP peer.""" vrf: str = "default" """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" - capabilities: List[MultiProtocolCaps] - """Multiprotocol capabilities""" + capabilities: list[MultiProtocolCaps] + """List of multiprotocol capabilities to be verified.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPPeerMPCaps.""" failures: dict[str, Any] = {"bgp_peers": {}} # Iterate over each bgp peer @@ -615,38 +635,35 @@ def test(self) -> None: class VerifyBGPPeerASNCap(AntaTest): - """ - Verifies the four octet asn capabilities of a BGP peer in a specified VRF. + """Verifies the four octet asn capabilities of a BGP peer in a specified VRF. + Expected results: - * success: The test will pass if BGP peer's four octet asn capabilities are advertised, received, and enabled in the specified VRF. - * failure: The test will fail if BGP peers are not found or four octet asn capabilities are not advertised, received, and enabled in the specified VRF. + * Success: The test will pass if BGP peer's four octet asn capabilities are advertised, received, and enabled in the specified VRF. + * Failure: The test will fail if BGP peers are not found or four octet asn capabilities are not advertised, received, and enabled in the specified VRF. """ name = "VerifyBGPPeerASNCap" - description = "Verifies the four octet asn capabilities of a BGP peer in a specified VRF." - categories = ["bgp"] - commands = [AntaCommand(command="show bgp neighbors vrf all")] + description = "Verifies the four octet asn capabilities of a BGP peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all")] class Input(AntaTest.Input): - """ - Input parameters of the testcase. - """ + """Input model for the VerifyBGPPeerASNCap test.""" - bgp_peers: List[BgpPeers] - """List of BGP peers""" + bgp_peers: list[BgpPeer] + """List of BGP peers.""" - class BgpPeers(BaseModel): - """ - This class defines the details of a BGP peer. - """ + class BgpPeer(BaseModel): + """Model for a BGP peer.""" peer_address: IPv4Address - """IPv4 address of a BGP peer""" + """IPv4 address of a BGP peer.""" vrf: str = "default" """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPPeerASNCap.""" failures: dict[str, Any] = {"bgp_peers": {}} # Iterate over each bgp peer @@ -684,38 +701,35 @@ def test(self) -> None: class VerifyBGPPeerRouteRefreshCap(AntaTest): - """ - Verifies the route refresh capabilities of a BGP peer in a specified VRF. + """Verifies the route refresh capabilities of a BGP peer in a specified VRF. + Expected results: - * success: The test will pass if the BGP peer's route refresh capabilities are advertised, received, and enabled in the specified VRF. - * failure: The test will fail if BGP peers are not found or route refresh capabilities are not advertised, received, and enabled in the specified VRF. + * Success: The test will pass if the BGP peer's route refresh capabilities are advertised, received, and enabled in the specified VRF. + * Failure: The test will fail if BGP peers are not found or route refresh capabilities are not advertised, received, and enabled in the specified VRF. """ name = "VerifyBGPPeerRouteRefreshCap" - description = "Verifies the route refresh capabilities of a BGP peer in a specified VRF." - categories = ["bgp"] - commands = [AntaCommand(command="show bgp neighbors vrf all")] + description = "Verifies the route refresh capabilities of a BGP peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all")] class Input(AntaTest.Input): - """ - Input parameters of the testcase. - """ + """Input model for the VerifyBGPPeerRouteRefreshCap test.""" - bgp_peers: List[BgpPeers] + bgp_peers: list[BgpPeer] """List of BGP peers""" - class BgpPeers(BaseModel): - """ - This class defines the details of a BGP peer. - """ + class BgpPeer(BaseModel): + """Model for a BGP peer.""" peer_address: IPv4Address - """IPv4 address of a BGP peer""" + """IPv4 address of a BGP peer.""" vrf: str = "default" """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPPeerRouteRefreshCap.""" failures: dict[str, Any] = {"bgp_peers": {}} # Iterate over each bgp peer @@ -753,30 +767,26 @@ def test(self) -> None: class VerifyBGPPeerMD5Auth(AntaTest): - """ - Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF. + """Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF. + Expected results: - * success: The test will pass if IPv4 BGP peers are configured with MD5 authentication and state as established in the specified VRF. - * failure: The test will fail if IPv4 BGP peers are not found, state is not as established or MD5 authentication is not enabled in the specified VRF. + * Success: The test will pass if IPv4 BGP peers are configured with MD5 authentication and state as established in the specified VRF. + * Failure: The test will fail if IPv4 BGP peers are not found, state is not as established or MD5 authentication is not enabled in the specified VRF. """ name = "VerifyBGPPeerMD5Auth" - description = "Verifies the MD5 authentication and state of IPv4 BGP peers in a specified VRF" - categories = ["routing", "bgp"] - commands = [AntaCommand(command="show bgp neighbors vrf all")] + description = "Verifies the MD5 authentication and state of a BGP peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all")] class Input(AntaTest.Input): - """ - Input parameters of the test case. - """ + """Input model for the VerifyBGPPeerMD5Auth test.""" - bgp_peers: List[BgpPeers] - """List of IPv4 BGP peers""" + bgp_peers: list[BgpPeer] + """List of IPv4 BGP peers.""" - class BgpPeers(BaseModel): - """ - This class defines the details of an IPv4 BGP peer. - """ + class BgpPeer(BaseModel): + """Model for a BGP peer.""" peer_address: IPv4Address """IPv4 address of BGP peer.""" @@ -785,6 +795,7 @@ class BgpPeers(BaseModel): @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPPeerMD5Auth.""" failures: dict[str, Any] = {"bgp_peers": {}} # Iterate over each command @@ -817,38 +828,39 @@ def test(self) -> None: class VerifyEVPNType2Route(AntaTest): - """ - This test verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI. + """Verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI. Expected Results: - * success: If all provided VXLAN endpoints have at least one valid and active path to their EVPN Type-2 routes. - * failure: If any of the provided VXLAN endpoints do not have at least one valid and active path to their EVPN Type-2 routes. + * Success: If all provided VXLAN endpoints have at least one valid and active path to their EVPN Type-2 routes. + * Failure: If any of the provided VXLAN endpoints do not have at least one valid and active path to their EVPN Type-2 routes. """ name = "VerifyEVPNType2Route" description = "Verifies the EVPN Type-2 routes for a given IPv4 or MAC address and VNI." - categories = ["routing", "bgp"] - commands = [AntaTemplate(template="show bgp evpn route-type mac-ip {address} vni {vni}")] + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show bgp evpn route-type mac-ip {address} vni {vni}")] class Input(AntaTest.Input): - """Inputs for the VerifyEVPNType2Route test.""" + """Input model for the VerifyEVPNType2Route test.""" - vxlan_endpoints: List[VxlanEndpoint] - """List of VXLAN endpoints to verify""" + vxlan_endpoints: list[VxlanEndpoint] + """List of VXLAN endpoints to verify.""" class VxlanEndpoint(BaseModel): - """VXLAN endpoint input model.""" + """Model for a VXLAN endpoint.""" - address: Union[IPv4Address, MacAddress] - """IPv4 or MAC address of the VXLAN endpoint""" + address: IPv4Address | MacAddress + """IPv4 or MAC address of the VXLAN endpoint.""" vni: Vni - """VNI of the VXLAN endpoint""" + """VNI of the VXLAN endpoint.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each VXLAN endpoint in the input list.""" return [template.render(address=endpoint.address, vni=endpoint.vni) for endpoint in self.inputs.vxlan_endpoints] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyEVPNType2Route.""" self.result.is_success() no_evpn_routes = [] bad_evpn_routes = [] @@ -879,30 +891,26 @@ def test(self) -> None: class VerifyBGPAdvCommunities(AntaTest): - """ - Verifies if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. + """Verifies if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. + Expected results: - * success: The test will pass if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. - * failure: The test will fail if the advertised communities of BGP peers are not standard, extended, and large in the specified VRF. + * Success: The test will pass if the advertised communities of BGP peers are standard, extended, and large in the specified VRF. + * Failure: The test will fail if the advertised communities of BGP peers are not standard, extended, and large in the specified VRF. """ name = "VerifyBGPAdvCommunities" - description = "Verifies if the advertised communities of BGP peers are standard, extended, and large in the specified VRF." - categories = ["routing", "bgp"] - commands = [AntaCommand(command="show bgp neighbors vrf all")] + description = "Verifies the advertised communities of a BGP peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all")] class Input(AntaTest.Input): - """ - Input parameters for the test. - """ + """Input model for the VerifyBGPAdvCommunities test.""" - bgp_peers: List[BgpPeers] - """List of BGP peers""" + bgp_peers: list[BgpPeer] + """List of BGP peers.""" - class BgpPeers(BaseModel): - """ - This class defines the details of a BGP peer. - """ + class BgpPeer(BaseModel): + """Model for a BGP peer.""" peer_address: IPv4Address """IPv4 address of a BGP peer.""" @@ -911,6 +919,7 @@ class BgpPeers(BaseModel): @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPAdvCommunities.""" failures: dict[str, Any] = {"bgp_peers": {}} # Iterate over each bgp peer @@ -941,42 +950,39 @@ def test(self) -> None: class VerifyBGPTimers(AntaTest): - """ - Verifies if the BGP peers are configured with the correct hold and keep-alive timers in the specified VRF. + """Verifies if the BGP peers are configured with the correct hold and keep-alive timers in the specified VRF. + Expected results: - * success: The test will pass if the hold and keep-alive timers are correct for BGP peers in the specified VRF. - * failure: The test will fail if BGP peers are not found or hold and keep-alive timers are not correct in the specified VRF. + * Success: The test will pass if the hold and keep-alive timers are correct for BGP peers in the specified VRF. + * Failure: The test will fail if BGP peers are not found or hold and keep-alive timers are not correct in the specified VRF. """ name = "VerifyBGPTimers" - description = "Verifies if the BGP peers are configured with the correct hold and keep alive timers in the specified VRF." - categories = ["routing", "bgp"] - commands = [AntaCommand(command="show bgp neighbors vrf all")] + description = "Verifies the timers of a BGP peer." + categories: ClassVar[list[str]] = ["bgp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show bgp neighbors vrf all")] class Input(AntaTest.Input): - """ - Input parameters for the test. - """ + """Input model for the VerifyBGPTimers test.""" - bgp_peers: List[BgpPeers] + bgp_peers: list[BgpPeer] """List of BGP peers""" - class BgpPeers(BaseModel): - """ - This class defines the details of a BGP peer. - """ + class BgpPeer(BaseModel): + """Model for a BGP peer.""" peer_address: IPv4Address - """IPv4 address of a BGP peer""" + """IPv4 address of a BGP peer.""" vrf: str = "default" """Optional VRF for BGP peer. If not provided, it defaults to `default`.""" hold_time: int = Field(ge=3, le=7200) - """BGP hold time in seconds""" + """BGP hold time in seconds.""" keep_alive_time: int = Field(ge=0, le=3600) - """BGP keep-alive time in seconds""" + """BGP keep-alive time in seconds.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBGPTimers.""" failures: dict[str, Any] = {} # Iterate over each bgp peer diff --git a/anta/tests/routing/generic.py b/anta/tests/routing/generic.py index 532b4bb75..3f76c3b1e 100644 --- a/anta/tests/routing/generic.py +++ b/anta/tests/routing/generic.py @@ -1,41 +1,42 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Generic routing test functions -""" +"""Module related to generic routing tests.""" + +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined from __future__ import annotations from ipaddress import IPv4Address, ip_interface - -# Need to keep List for pydantic in python 3.8 -from typing import List, Literal +from typing import ClassVar, Literal from pydantic import model_validator from anta.models import AntaCommand, AntaTemplate, AntaTest -# Mypy does not understand AntaTest.Input typing -# mypy: disable-error-code=attr-defined - class VerifyRoutingProtocolModel(AntaTest): - """ - Verifies the configured routing protocol model is the one we expect. - And if there is no mismatch between the configured and operating routing protocol model. + """Verifies the configured routing protocol model is the one we expect. + + Expected Results: + * Success: The test will pass if the configured routing protocol model is the one we expect. + * Failure: The test will fail if the configured routing protocol model is not the one we expect. """ name = "VerifyRoutingProtocolModel" description = "Verifies the configured routing protocol model." - categories = ["routing"] - commands = [AntaCommand(command="show ip route summary", revision=3)] + categories: ClassVar[list[str]] = ["routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyRoutingProtocolModel test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring model: Literal["multi-agent", "ribd"] = "multi-agent" - """Expected routing protocol model""" + """Expected routing protocol model. Defaults to `multi-agent`.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyRoutingProtocolModel.""" command_output = self.instance_commands[0].json_output configured_model = command_output["protoModelStatus"]["configuredProtoModel"] operating_model = command_output["protoModelStatus"]["operatingProtoModel"] @@ -46,31 +47,37 @@ def test(self) -> None: class VerifyRoutingTableSize(AntaTest): - """ - Verifies the size of the IP routing table (default VRF). - Should be between the two provided thresholds. + """Verifies the size of the IP routing table of the default VRF. + + Expected Results: + * Success: The test will pass if the routing table size is between the provided minimum and maximum values. + * Failure: The test will fail if the routing table size is not between the provided minimum and maximum values. """ name = "VerifyRoutingTableSize" - description = "Verifies the size of the IP routing table (default VRF). Should be between the two provided thresholds." - categories = ["routing"] - commands = [AntaCommand(command="show ip route summary", revision=3)] + description = "Verifies the size of the IP routing table of the default VRF." + categories: ClassVar[list[str]] = ["routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip route summary", revision=3)] + + class Input(AntaTest.Input): + """Input model for the VerifyRoutingTableSize test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring minimum: int - """Expected minimum routing table (default VRF) size""" + """Expected minimum routing table size.""" maximum: int - """Expected maximum routing table (default VRF) size""" + """Expected maximum routing table size.""" - @model_validator(mode="after") # type: ignore + @model_validator(mode="after") # type: ignore[misc] def check_min_max(self) -> AntaTest.Input: - """Validate that maximum is greater than minimum""" + """Validate that maximum is greater than minimum.""" if self.minimum > self.maximum: - raise ValueError(f"Minimum {self.minimum} is greater than maximum {self.maximum}") + msg = f"Minimum {self.minimum} is greater than maximum {self.maximum}" + raise ValueError(msg) return self @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyRoutingTableSize.""" command_output = self.instance_commands[0].json_output total_routes = int(command_output["vrfs"]["default"]["totalRoutes"]) if self.inputs.minimum <= total_routes <= self.inputs.maximum: @@ -80,36 +87,39 @@ def test(self) -> None: class VerifyRoutingTableEntry(AntaTest): - """ - This test verifies that the provided routes are present in the routing table of a specified VRF. + """Verifies that the provided routes are present in the routing table of a specified VRF. Expected Results: - * success: The test will pass if the provided routes are present in the routing table. - * failure: The test will fail if one or many provided routes are missing from the routing table. + * Success: The test will pass if the provided routes are present in the routing table. + * Failure: The test will fail if one or many provided routes are missing from the routing table. """ name = "VerifyRoutingTableEntry" description = "Verifies that the provided routes are present in the routing table of a specified VRF." - categories = ["routing"] - commands = [AntaTemplate(template="show ip route vrf {vrf} {route}")] + categories: ClassVar[list[str]] = ["routing"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip route vrf {vrf} {route}")] + + class Input(AntaTest.Input): + """Input model for the VerifyRoutingTableEntry test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring vrf: str = "default" - """VRF context""" - routes: List[IPv4Address] - """Routes to verify""" + """VRF context. Defaults to `default` VRF.""" + routes: list[IPv4Address] + """List of routes to verify.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each route in the input list.""" return [template.render(vrf=self.inputs.vrf, route=route) for route in self.inputs.routes] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyRoutingTableEntry.""" missing_routes = [] for command in self.instance_commands: if "vrf" in command.params and "route" in command.params: vrf, route = command.params["vrf"], command.params["route"] - if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(list(routes)[0]).ip: + if len(routes := command.json_output["vrfs"][vrf]["routes"]) == 0 or route != ip_interface(next(iter(routes))).ip: missing_routes.append(str(route)) if not missing_routes: diff --git a/anta/tests/routing/ospf.py b/anta/tests/routing/ospf.py index 844fcf19b..a6d7539a8 100644 --- a/anta/tests/routing/ospf.py +++ b/anta/tests/routing/ospf.py @@ -1,61 +1,82 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -OSPF test functions -""" +"""Module related to OSPF tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any, ClassVar from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + def _count_ospf_neighbor(ospf_neighbor_json: dict[str, Any]) -> int: - """ - Count the number of OSPF neighbors + """Count the number of OSPF neighbors. + + Args: + ---- + ospf_neighbor_json (dict[str, Any]): The JSON output of the `show ip ospf neighbor` command. + + Returns: + ------- + int: The number of OSPF neighbors. + """ count = 0 - for _, vrf_data in ospf_neighbor_json["vrfs"].items(): - for _, instance_data in vrf_data["instList"].items(): + for vrf_data in ospf_neighbor_json["vrfs"].values(): + for instance_data in vrf_data["instList"].values(): count += len(instance_data.get("ospfNeighborEntries", [])) return count def _get_not_full_ospf_neighbors(ospf_neighbor_json: dict[str, Any]) -> list[dict[str, Any]]: + """Return the OSPF neighbors whose adjacency state is not `full`. + + Args: + ---- + ospf_neighbor_json (dict[str, Any]): The JSON output of the `show ip ospf neighbor` command. + + Returns: + ------- + list[dict[str, Any]]: A list of OSPF neighbors whose adjacency state is not `full`. + """ - Return the OSPF neighbors whose adjacency state is not "full" - """ - not_full_neighbors = [] - for vrf, vrf_data in ospf_neighbor_json["vrfs"].items(): - for instance, instance_data in vrf_data["instList"].items(): - for neighbor_data in instance_data.get("ospfNeighborEntries", []): - if (state := neighbor_data["adjacencyState"]) != "full": - not_full_neighbors.append( - { - "vrf": vrf, - "instance": instance, - "neighbor": neighbor_data["routerId"], - "state": state, - } - ) - return not_full_neighbors + return [ + { + "vrf": vrf, + "instance": instance, + "neighbor": neighbor_data["routerId"], + "state": state, + } + for vrf, vrf_data in ospf_neighbor_json["vrfs"].items() + for instance, instance_data in vrf_data["instList"].items() + for neighbor_data in instance_data.get("ospfNeighborEntries", []) + if (state := neighbor_data["adjacencyState"]) != "full" + ] class VerifyOSPFNeighborState(AntaTest): - """ - Verifies all OSPF neighbors are in FULL state. + """Verifies all OSPF neighbors are in FULL state. + + Expected Results: + * Success: The test will pass if all OSPF neighbors are in FULL state. + * Failure: The test will fail if some OSPF neighbors are not in FULL state. + * Skipped: The test will be skipped if no OSPF neighbor is found. """ name = "VerifyOSPFNeighborState" description = "Verifies all OSPF neighbors are in FULL state." - categories = ["ospf"] - commands = [AntaCommand(command="show ip ospf neighbor")] + categories: ClassVar[list[str]] = ["ospf"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyOSPFNeighborState.""" command_output = self.instance_commands[0].json_output if _count_ospf_neighbor(command_output) == 0: self.result.is_skipped("no OSPF neighbor found") @@ -67,21 +88,28 @@ def test(self) -> None: class VerifyOSPFNeighborCount(AntaTest): - """ - Verifies the number of OSPF neighbors in FULL state is the one we expect. + """Verifies the number of OSPF neighbors in FULL state is the one we expect. + + Expected Results: + * Success: The test will pass if the number of OSPF neighbors in FULL state is the one we expect. + * Failure: The test will fail if the number of OSPF neighbors in FULL state is not the one we expect. + * Skipped: The test will be skipped if no OSPF neighbor is found. """ name = "VerifyOSPFNeighborCount" description = "Verifies the number of OSPF neighbors in FULL state is the one we expect." - categories = ["ospf"] - commands = [AntaCommand(command="show ip ospf neighbor")] + categories: ClassVar[list[str]] = ["ospf"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip ospf neighbor")] + + class Input(AntaTest.Input): + """Input model for the VerifyOSPFNeighborCount test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring number: int - """The expected number of OSPF neighbors in FULL state""" + """The expected number of OSPF neighbors in FULL state.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyOSPFNeighborCount.""" command_output = self.instance_commands[0].json_output if (neighbor_count := _count_ospf_neighbor(command_output)) == 0: self.result.is_skipped("no OSPF neighbor found") @@ -90,6 +118,5 @@ def test(self) -> None: if neighbor_count != self.inputs.number: self.result.is_failure(f"device has {neighbor_count} neighbors (expected {self.inputs.number})") not_full_neighbors = _get_not_full_ospf_neighbors(command_output) - print(not_full_neighbors) if not_full_neighbors: self.result.is_failure(f"Some neighbors are not correctly configured: {not_full_neighbors}.") diff --git a/anta/tests/security.py b/anta/tests/security.py index ba7f4e58d..9e411a06f 100644 --- a/anta/tests/security.py +++ b/anta/tests/security.py @@ -1,19 +1,18 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the EOS various security settings -""" +"""Module related to the EOS various security tests.""" + from __future__ import annotations # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined -from datetime import datetime -from typing import List, Union +from datetime import datetime, timezone +from typing import ClassVar -from pydantic import BaseModel, Field, conint, model_validator +from pydantic import BaseModel, Field, model_validator -from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize +from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize from anta.models import AntaCommand, AntaTemplate, AntaTest from anta.tools.get_item import get_item from anta.tools.get_value import get_value @@ -21,24 +20,24 @@ class VerifySSHStatus(AntaTest): - """ - Verifies if the SSHD agent is disabled in the default VRF. + """Verifies if the SSHD agent is disabled in the default VRF. Expected Results: - * success: The test will pass if the SSHD agent is disabled in the default VRF. - * failure: The test will fail if the SSHD agent is NOT disabled in the default VRF. + * Success: The test will pass if the SSHD agent is disabled in the default VRF. + * Failure: The test will fail if the SSHD agent is NOT disabled in the default VRF. """ name = "VerifySSHStatus" description = "Verifies if the SSHD agent is disabled in the default VRF." - categories = ["security"] - commands = [AntaCommand(command="show management ssh", ofmt="text")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh", ofmt="text")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySSHStatus.""" command_output = self.instance_commands[0].text_output - line = [line for line in command_output.split("\n") if line.startswith("SSHD status")][0] + line = next(line for line in command_output.split("\n") if line.startswith("SSHD status")) status = line.split("is ")[1] if status == "disabled": @@ -48,97 +47,99 @@ def test(self) -> None: class VerifySSHIPv4Acl(AntaTest): - """ - Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF. + """Verifies if the SSHD agent has the right number IPv4 ACL(s) configured for a specified VRF. Expected results: - * success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF. - * failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF. + * Success: The test will pass if the SSHD agent has the provided number of IPv4 ACL(s) in the specified VRF. + * Failure: The test will fail if the SSHD agent has not the right number of IPv4 ACL(s) in the specified VRF. """ name = "VerifySSHIPv4Acl" description = "Verifies if the SSHD agent has IPv4 ACL(s) configured." - categories = ["security"] - commands = [AntaCommand(command="show management ssh ip access-list summary")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ip access-list summary")] + + class Input(AntaTest.Input): + """Input model for the VerifySSHIPv4Acl test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type:ignore - """The number of expected IPv4 ACL(s)""" + number: PositiveInteger + """The number of expected IPv4 ACL(s).""" vrf: str = "default" - """The name of the VRF in which to check for the SSHD agent""" + """The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySSHIPv4Acl.""" command_output = self.instance_commands[0].json_output ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) - not_configured_acl_list = [] if ipv4_acl_number != self.inputs.number: self.result.is_failure(f"Expected {self.inputs.number} SSH IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") return - for ipv4_acl in ipv4_acl_list: - if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]: - not_configured_acl_list.append(ipv4_acl["name"]) - if not_configured_acl_list: - self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + + not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] + + if not_configured_acl: + self.result.is_failure(f"SSH IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") else: self.result.is_success() class VerifySSHIPv6Acl(AntaTest): - """ - Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF. + """Verifies if the SSHD agent has the right number IPv6 ACL(s) configured for a specified VRF. Expected results: - * success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF. - * failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF. + * Success: The test will pass if the SSHD agent has the provided number of IPv6 ACL(s) in the specified VRF. + * Failure: The test will fail if the SSHD agent has not the right number of IPv6 ACL(s) in the specified VRF. """ name = "VerifySSHIPv6Acl" description = "Verifies if the SSHD agent has IPv6 ACL(s) configured." - categories = ["security"] - commands = [AntaCommand(command="show management ssh ipv6 access-list summary")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management ssh ipv6 access-list summary")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type:ignore - """The number of expected IPv6 ACL(s)""" + class Input(AntaTest.Input): + """Input model for the VerifySSHIPv6Acl test.""" + + number: PositiveInteger + """The number of expected IPv6 ACL(s).""" vrf: str = "default" - """The name of the VRF in which to check for the SSHD agent""" + """The name of the VRF in which to check for the SSHD agent. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySSHIPv6Acl.""" command_output = self.instance_commands[0].json_output ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) - not_configured_acl_list = [] if ipv6_acl_number != self.inputs.number: self.result.is_failure(f"Expected {self.inputs.number} SSH IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") return - for ipv6_acl in ipv6_acl_list: - if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]: - not_configured_acl_list.append(ipv6_acl["name"]) - if not_configured_acl_list: - self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + + not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] + + if not_configured_acl: + self.result.is_failure(f"SSH IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") else: self.result.is_success() class VerifyTelnetStatus(AntaTest): - """ - Verifies if Telnet is disabled in the default VRF. + """Verifies if Telnet is disabled in the default VRF. Expected Results: - * success: The test will pass if Telnet is disabled in the default VRF. - * failure: The test will fail if Telnet is NOT disabled in the default VRF. + * Success: The test will pass if Telnet is disabled in the default VRF. + * Failure: The test will fail if Telnet is NOT disabled in the default VRF. """ name = "VerifyTelnetStatus" description = "Verifies if Telnet is disabled in the default VRF." - categories = ["security"] - commands = [AntaCommand(command="show management telnet")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management telnet")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTelnetStatus.""" command_output = self.instance_commands[0].json_output if command_output["serverState"] == "disabled": self.result.is_success() @@ -147,21 +148,21 @@ def test(self) -> None: class VerifyAPIHttpStatus(AntaTest): - """ - Verifies if eAPI HTTP server is disabled globally. + """Verifies if eAPI HTTP server is disabled globally. Expected Results: - * success: The test will pass if eAPI HTTP server is disabled globally. - * failure: The test will fail if eAPI HTTP server is NOT disabled globally. + * Success: The test will pass if eAPI HTTP server is disabled globally. + * Failure: The test will fail if eAPI HTTP server is NOT disabled globally. """ name = "VerifyAPIHttpStatus" description = "Verifies if eAPI HTTP server is disabled globally." - categories = ["security"] - commands = [AntaCommand(command="show management api http-commands")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAPIHttpStatus.""" command_output = self.instance_commands[0].json_output if command_output["enabled"] and not command_output["httpServer"]["running"]: self.result.is_success() @@ -170,25 +171,27 @@ def test(self) -> None: class VerifyAPIHttpsSSL(AntaTest): - """ - Verifies if eAPI HTTPS server SSL profile is configured and valid. + """Verifies if eAPI HTTPS server SSL profile is configured and valid. Expected results: - * success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid. - * failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid. + * Success: The test will pass if the eAPI HTTPS server SSL profile is configured and valid. + * Failure: The test will fail if the eAPI HTTPS server SSL profile is NOT configured, misconfigured or invalid. """ name = "VerifyAPIHttpsSSL" description = "Verifies if the eAPI has a valid SSL profile." - categories = ["security"] - commands = [AntaCommand(command="show management api http-commands")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands")] + + class Input(AntaTest.Input): + """Input model for the VerifyAPIHttpsSSL test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring profile: str - """SSL profile to verify""" + """SSL profile to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAPIHttpsSSL.""" command_output = self.instance_commands[0].json_output try: if command_output["sslProfile"]["name"] == self.inputs.profile and command_output["sslProfile"]["state"] == "valid": @@ -201,110 +204,107 @@ def test(self) -> None: class VerifyAPIIPv4Acl(AntaTest): - """ - Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF. + """Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF. Expected results: - * success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF. - * failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF. + * Success: The test will pass if eAPI has the provided number of IPv4 ACL(s) in the specified VRF. + * Failure: The test will fail if eAPI has not the right number of IPv4 ACL(s) in the specified VRF. """ name = "VerifyAPIIPv4Acl" description = "Verifies if eAPI has the right number IPv4 ACL(s) configured for a specified VRF." - categories = ["security"] - commands = [AntaCommand(command="show management api http-commands ip access-list summary")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ip access-list summary")] + + class Input(AntaTest.Input): + """Input parameters for the VerifyAPIIPv4Acl test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type:ignore - """The number of expected IPv4 ACL(s)""" + number: PositiveInteger + """The number of expected IPv4 ACL(s).""" vrf: str = "default" - """The name of the VRF in which to check for eAPI""" + """The name of the VRF in which to check for eAPI. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAPIIPv4Acl.""" command_output = self.instance_commands[0].json_output ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) - not_configured_acl_list = [] if ipv4_acl_number != self.inputs.number: self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") return - for ipv4_acl in ipv4_acl_list: - if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]: - not_configured_acl_list.append(ipv4_acl["name"]) - if not_configured_acl_list: - self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + + not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] + + if not_configured_acl: + self.result.is_failure(f"eAPI IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") else: self.result.is_success() class VerifyAPIIPv6Acl(AntaTest): - """ - Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF. + """Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF. Expected results: - * success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF. - * failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF. + * Success: The test will pass if eAPI has the provided number of IPv6 ACL(s) in the specified VRF. + * Failure: The test will fail if eAPI has not the right number of IPv6 ACL(s) in the specified VRF. * skipped: The test will be skipped if the number of IPv6 ACL(s) or VRF parameter is not provided. """ name = "VerifyAPIIPv6Acl" description = "Verifies if eAPI has the right number IPv6 ACL(s) configured for a specified VRF." - categories = ["security"] - commands = [AntaCommand(command="show management api http-commands ipv6 access-list summary")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management api http-commands ipv6 access-list summary")] + + class Input(AntaTest.Input): + """Input parameters for the VerifyAPIIPv6Acl test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type:ignore - """The number of expected IPv6 ACL(s)""" + number: PositiveInteger + """The number of expected IPv6 ACL(s).""" vrf: str = "default" - """The name of the VRF in which to check for eAPI""" + """The name of the VRF in which to check for eAPI. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAPIIPv6Acl.""" command_output = self.instance_commands[0].json_output ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) - not_configured_acl_list = [] if ipv6_acl_number != self.inputs.number: self.result.is_failure(f"Expected {self.inputs.number} eAPI IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") return - for ipv6_acl in ipv6_acl_list: - if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]: - not_configured_acl_list.append(ipv6_acl["name"]) - if not_configured_acl_list: - self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + + not_configured_acl = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] + + if not_configured_acl: + self.result.is_failure(f"eAPI IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") else: self.result.is_success() class VerifyAPISSLCertificate(AntaTest): - """ - Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size. + """Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size. Expected Results: - * success: The test will pass if the certificate's expiry date is greater than the threshold, + * Success: The test will pass if the certificate's expiry date is greater than the threshold, and the certificate has the correct name, encryption algorithm, and key size. - * failure: The test will fail if the certificate is expired or is going to expire, + * Failure: The test will fail if the certificate is expired or is going to expire, or if the certificate has an incorrect name, encryption algorithm, or key size. """ name = "VerifyAPISSLCertificate" description = "Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size." - categories = ["security"] - commands = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show management security ssl certificate"), AntaCommand(command="show clock")] class Input(AntaTest.Input): - """ - Input parameters for the VerifyAPISSLCertificate test. - """ + """Input parameters for the VerifyAPISSLCertificate test.""" - certificates: List[APISSLCertificates] - """List of API SSL certificates""" + certificates: list[APISSLCertificate] + """List of API SSL certificates.""" - class APISSLCertificates(BaseModel): - """ - This class defines the details of an API SSL certificate. - """ + class APISSLCertificate(BaseModel): + """Model for an API SSL certificate.""" certificate_name: str """The name of the certificate to be verified.""" @@ -314,31 +314,30 @@ class APISSLCertificates(BaseModel): """The common subject name of the certificate.""" encryption_algorithm: EncryptionAlgorithm """The encryption algorithm of the certificate.""" - key_size: Union[RsaKeySize, EcdsaKeySize] + key_size: RsaKeySize | EcdsaKeySize """The encryption algorithm key size of the certificate.""" @model_validator(mode="after") def validate_inputs(self: BaseModel) -> BaseModel: - """ - Validate the key size provided to the APISSLCertificates class. + """Validate the key size provided to the APISSLCertificates class. If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}. If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}. """ - if self.encryption_algorithm == "RSA" and self.key_size not in RsaKeySize.__args__: - raise ValueError(f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}.") + msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {RsaKeySize.__args__}." + raise ValueError(msg) if self.encryption_algorithm == "ECDSA" and self.key_size not in EcdsaKeySize.__args__: - raise ValueError( - f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}." - ) + msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {EcdsaKeySize.__args__}." + raise ValueError(msg) return self @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAPISSLCertificate.""" # Mark the result as success by default self.result.is_success() @@ -356,7 +355,7 @@ def test(self) -> None: continue expiry_time = certificate_data["notAfter"] - day_difference = (datetime.fromtimestamp(expiry_time) - datetime.fromtimestamp(current_timestamp)).days + day_difference = (datetime.fromtimestamp(expiry_time, tz=timezone.utc) - datetime.fromtimestamp(current_timestamp, tz=timezone.utc)).days # Verify certificate expiry if 0 < day_difference < certificate.expiry_threshold: @@ -381,27 +380,27 @@ def test(self) -> None: class VerifyBannerLogin(AntaTest): - """ - Verifies the login banner of a device. + """Verifies the login banner of a device. Expected results: - * success: The test will pass if the login banner matches the provided input. - * failure: The test will fail if the login banner does not match the provided input. + * Success: The test will pass if the login banner matches the provided input. + * Failure: The test will fail if the login banner does not match the provided input. """ name = "VerifyBannerLogin" description = "Verifies the login banner of a device." - categories = ["security"] - commands = [AntaCommand(command="show banner login")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner login")] class Input(AntaTest.Input): - """Defines the input parameters for this test case.""" + """Input model for the VerifyBannerLogin test.""" login_banner: str """Expected login banner of the device.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBannerLogin.""" login_banner = self.instance_commands[0].json_output["loginBanner"] # Remove leading and trailing whitespaces from each line @@ -413,27 +412,27 @@ def test(self) -> None: class VerifyBannerMotd(AntaTest): - """ - Verifies the motd banner of a device. + """Verifies the motd banner of a device. Expected results: - * success: The test will pass if the motd banner matches the provided input. - * failure: The test will fail if the motd banner does not match the provided input. + * Success: The test will pass if the motd banner matches the provided input. + * Failure: The test will fail if the motd banner does not match the provided input. """ name = "VerifyBannerMotd" description = "Verifies the motd banner of a device." - categories = ["security"] - commands = [AntaCommand(command="show banner motd")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show banner motd")] class Input(AntaTest.Input): - """Defines the input parameters for this test case.""" + """Input model for the VerifyBannerMotd test.""" motd_banner: str """Expected motd banner of the device.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyBannerMotd.""" motd_banner = self.instance_commands[0].json_output["motd"] # Remove leading and trailing whitespaces from each line @@ -445,47 +444,48 @@ def test(self) -> None: class VerifyIPv4ACL(AntaTest): - """ - Verifies the configuration of IPv4 ACLs. + """Verifies the configuration of IPv4 ACLs. Expected results: - * success: The test will pass if an IPv4 ACL is configured with the correct sequence entries. - * failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence. + * Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries. + * Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence. """ name = "VerifyIPv4ACL" description = "Verifies the configuration of IPv4 ACLs." - categories = ["security"] - commands = [AntaTemplate(template="show ip access-lists {acl}")] + categories: ClassVar[list[str]] = ["security"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}")] class Input(AntaTest.Input): - """Inputs for the VerifyIPv4ACL test.""" + """Input model for the VerifyIPv4ACL test.""" - ipv4_access_lists: List[IPv4ACL] - """List of IPv4 ACLs to verify""" + ipv4_access_lists: list[IPv4ACL] + """List of IPv4 ACLs to verify.""" class IPv4ACL(BaseModel): - """Detail of IPv4 ACL""" + """Model for an IPv4 ACL.""" name: str - """Name of IPv4 ACL""" + """Name of IPv4 ACL.""" - entries: List[IPv4ACLEntries] - """List of IPv4 ACL entries""" + entries: list[IPv4ACLEntry] + """List of IPv4 ACL entries.""" - class IPv4ACLEntries(BaseModel): - """IPv4 ACL entries details""" + class IPv4ACLEntry(BaseModel): + """Model for an IPv4 ACL entry.""" sequence: int = Field(ge=1, le=4294967295) - """Sequence number of an ACL entry""" + """Sequence number of an ACL entry.""" action: str - """Action of an ACL entry""" + """Action of an ACL entry.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each input ACL.""" return [template.render(acl=acl.name, entries=acl.entries) for acl in self.inputs.ipv4_access_lists] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyIPv4ACL.""" self.result.is_success() for command_output in self.instance_commands: # Collecting input ACL details diff --git a/anta/tests/services.py b/anta/tests/services.py index a2c21364d..edbc12e92 100644 --- a/anta/tests/services.py +++ b/anta/tests/services.py @@ -1,13 +1,14 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the EOS various services settings -""" +"""Module related to the EOS various services tests.""" + from __future__ import annotations +# Mypy does not understand AntaTest.Input typing +# mypy: disable-error-code=attr-defined from ipaddress import IPv4Address, IPv6Address -from typing import List, Union +from typing import ClassVar from pydantic import BaseModel, Field @@ -17,32 +18,29 @@ from anta.tools.get_item import get_item from anta.tools.utils import get_failed_logs -# Mypy does not understand AntaTest.Input typing -# mypy: disable-error-code=attr-defined - class VerifyHostname(AntaTest): - """ - Verifies the hostname of a device. + """Verifies the hostname of a device. Expected results: - * success: The test will pass if the hostname matches the provided input. - * failure: The test will fail if the hostname does not match the provided input. + * Success: The test will pass if the hostname matches the provided input. + * Failure: The test will fail if the hostname does not match the provided input. """ name = "VerifyHostname" description = "Verifies the hostname of a device." - categories = ["services"] - commands = [AntaCommand(command="show hostname")] + categories: ClassVar[list[str]] = ["services"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show hostname")] class Input(AntaTest.Input): - """Defines the input parameters for this test case.""" + """Input model for the VerifyHostname test.""" hostname: str """Expected hostname of the device.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyHostname.""" hostname = self.instance_commands[0].json_output["hostname"] if hostname != self.inputs.hostname: @@ -52,31 +50,32 @@ def test(self) -> None: class VerifyDNSLookup(AntaTest): - """ - This class verifies the DNS (Domain name service) name to IP address resolution. + """Verifies the DNS (Domain Name Service) name to IP address resolution. Expected Results: - * success: The test will pass if a domain name is resolved to an IP address. - * failure: The test will fail if a domain name does not resolve to an IP address. - * error: This test will error out if a domain name is invalid. + * Success: The test will pass if a domain name is resolved to an IP address. + * Failure: The test will fail if a domain name does not resolve to an IP address. + * Error: This test will error out if a domain name is invalid. """ name = "VerifyDNSLookup" description = "Verifies the DNS name to IP address resolution." - categories = ["services"] - commands = [AntaTemplate(template="bash timeout 10 nslookup {domain}")] + categories: ClassVar[list[str]] = ["services"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="bash timeout 10 nslookup {domain}")] class Input(AntaTest.Input): - """Inputs for the VerifyDNSLookup test.""" + """Input model for the VerifyDNSLookup test.""" - domain_names: List[str] - """List of domain names""" + domain_names: list[str] + """List of domain names.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each domain name in the input list.""" return [template.render(domain=domain_name) for domain_name in self.inputs.domain_names] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyDNSLookup.""" self.result.is_success() failed_domains = [] for command in self.instance_commands: @@ -89,29 +88,28 @@ def test(self) -> None: class VerifyDNSServers(AntaTest): - """ - Verifies if the DNS (Domain Name Service) servers are correctly configured. + """Verifies if the DNS (Domain Name Service) servers are correctly configured. Expected Results: - * success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority. - * failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input. + * Success: The test will pass if the DNS server specified in the input is configured with the correct VRF and priority. + * Failure: The test will fail if the DNS server is not configured or if the VRF and priority of the DNS server do not match the input. """ name = "VerifyDNSServers" description = "Verifies if the DNS servers are correctly configured." - categories = ["services"] - commands = [AntaCommand(command="show ip name-server")] + categories: ClassVar[list[str]] = ["services"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip name-server")] class Input(AntaTest.Input): - """Inputs for the VerifyDNSServers test.""" + """Input model for the VerifyDNSServers test.""" - dns_servers: List[DnsServers] + dns_servers: list[DnsServer] """List of DNS servers to verify.""" - class DnsServers(BaseModel): - """DNS server details""" + class DnsServer(BaseModel): + """Model for a DNS server.""" - server_address: Union[IPv4Address, IPv6Address] + server_address: IPv4Address | IPv6Address """The IPv4/IPv6 address of the DNS server.""" vrf: str = "default" """The VRF for the DNS server. Defaults to 'default' if not provided.""" @@ -120,6 +118,7 @@ class DnsServers(BaseModel): @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyDNSServers.""" command_output = self.instance_commands[0].json_output["nameServerConfigs"] self.result.is_success() for server in self.inputs.dns_servers: @@ -141,8 +140,7 @@ def test(self) -> None: class VerifyErrdisableRecovery(AntaTest): - """ - Verifies the errdisable recovery reason, status, and interval. + """Verifies the errdisable recovery reason, status, and interval. Expected Results: * Success: The test will pass if the errdisable recovery reason status is enabled and the interval matches the input. @@ -151,25 +149,27 @@ class VerifyErrdisableRecovery(AntaTest): name = "VerifyErrdisableRecovery" description = "Verifies the errdisable recovery reason, status, and interval." - categories = ["services"] - commands = [AntaCommand(command="show errdisable recovery", ofmt="text")] # Command does not support JSON output hence using text output + categories: ClassVar[list[str]] = ["services"] + # NOTE: Only `text` output format is supported for this command + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show errdisable recovery", ofmt="text")] class Input(AntaTest.Input): - """Inputs for the VerifyErrdisableRecovery test.""" + """Input model for the VerifyErrdisableRecovery test.""" - reasons: List[ErrDisableReason] - """List of errdisable reasons""" + reasons: list[ErrDisableReason] + """List of errdisable reasons.""" class ErrDisableReason(BaseModel): - """Details of an errdisable reason""" + """Model for an errdisable reason.""" reason: ErrDisableReasons - """Type or name of the errdisable reason""" + """Type or name of the errdisable reason.""" interval: ErrDisableInterval - """Interval of the reason in seconds""" + """Interval of the reason in seconds.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyErrdisableRecovery.""" command_output = self.instance_commands[0].text_output self.result.is_success() for error_reason in self.inputs.reasons: diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index 39d94245c..25ab802b5 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -1,38 +1,43 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the EOS various SNMP settings -""" +"""Module related to the EOS various SNMP tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -from pydantic import conint +from typing import TYPE_CHECKING, ClassVar +from anta.custom_types import PositiveInteger from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifySnmpStatus(AntaTest): - """ - Verifies whether the SNMP agent is enabled in a specified VRF. + """Verifies whether the SNMP agent is enabled in a specified VRF. Expected Results: - * success: The test will pass if the SNMP agent is enabled in the specified VRF. - * failure: The test will fail if the SNMP agent is disabled in the specified VRF. + * Success: The test will pass if the SNMP agent is enabled in the specified VRF. + * Failure: The test will fail if the SNMP agent is disabled in the specified VRF. """ name = "VerifySnmpStatus" description = "Verifies if the SNMP agent is enabled." - categories = ["snmp"] - commands = [AntaCommand(command="show snmp")] + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp")] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpStatus test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring vrf: str = "default" - """The name of the VRF in which to check for the SNMP agent""" + """The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySnmpStatus.""" command_output = self.instance_commands[0].json_output if command_output["enabled"] and self.inputs.vrf in command_output["vrfs"]["snmpVrfs"]: self.result.is_success() @@ -41,103 +46,105 @@ def test(self) -> None: class VerifySnmpIPv4Acl(AntaTest): - """ - Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF. + """Verifies if the SNMP agent has the right number IPv4 ACL(s) configured for a specified VRF. Expected results: - * success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF. - * failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF. + * Success: The test will pass if the SNMP agent has the provided number of IPv4 ACL(s) in the specified VRF. + * Failure: The test will fail if the SNMP agent has not the right number of IPv4 ACL(s) in the specified VRF. """ name = "VerifySnmpIPv4Acl" description = "Verifies if the SNMP agent has IPv4 ACL(s) configured." - categories = ["snmp"] - commands = [AntaCommand(command="show snmp ipv4 access-list summary")] + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv4 access-list summary")] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpIPv4Acl test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type:ignore - """The number of expected IPv4 ACL(s)""" + number: PositiveInteger + """The number of expected IPv4 ACL(s).""" vrf: str = "default" - """The name of the VRF in which to check for the SNMP agent""" + """The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySnmpIPv4Acl.""" command_output = self.instance_commands[0].json_output ipv4_acl_list = command_output["ipAclList"]["aclList"] ipv4_acl_number = len(ipv4_acl_list) - not_configured_acl_list = [] if ipv4_acl_number != self.inputs.number: self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv4 ACL(s) in vrf {self.inputs.vrf} but got {ipv4_acl_number}") return - for ipv4_acl in ipv4_acl_list: - if self.inputs.vrf not in ipv4_acl["configuredVrfs"] or self.inputs.vrf not in ipv4_acl["activeVrfs"]: - not_configured_acl_list.append(ipv4_acl["name"]) - if not_configured_acl_list: - self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + + not_configured_acl = [acl["name"] for acl in ipv4_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] + + if not_configured_acl: + self.result.is_failure(f"SNMP IPv4 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl}") else: self.result.is_success() class VerifySnmpIPv6Acl(AntaTest): - """ - Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF. + """Verifies if the SNMP agent has the right number IPv6 ACL(s) configured for a specified VRF. Expected results: - * success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF. - * failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF. + * Success: The test will pass if the SNMP agent has the provided number of IPv6 ACL(s) in the specified VRF. + * Failure: The test will fail if the SNMP agent has not the right number of IPv6 ACL(s) in the specified VRF. """ name = "VerifySnmpIPv6Acl" description = "Verifies if the SNMP agent has IPv6 ACL(s) configured." - categories = ["snmp"] - commands = [AntaCommand(command="show snmp ipv6 access-list summary")] + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp ipv6 access-list summary")] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpIPv6Acl test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - number: conint(ge=0) # type:ignore - """The number of expected IPv6 ACL(s)""" + number: PositiveInteger + """The number of expected IPv6 ACL(s).""" vrf: str = "default" - """The name of the VRF in which to check for the SNMP agent""" + """The name of the VRF in which to check for the SNMP agent. Defaults to `default` VRF.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySnmpIPv6Acl.""" command_output = self.instance_commands[0].json_output ipv6_acl_list = command_output["ipv6AclList"]["aclList"] ipv6_acl_number = len(ipv6_acl_list) - not_configured_acl_list = [] if ipv6_acl_number != self.inputs.number: self.result.is_failure(f"Expected {self.inputs.number} SNMP IPv6 ACL(s) in vrf {self.inputs.vrf} but got {ipv6_acl_number}") return - for ipv6_acl in ipv6_acl_list: - if self.inputs.vrf not in ipv6_acl["configuredVrfs"] or self.inputs.vrf not in ipv6_acl["activeVrfs"]: - not_configured_acl_list.append(ipv6_acl["name"]) - if not_configured_acl_list: - self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {not_configured_acl_list}") + + acl_not_configured = [acl["name"] for acl in ipv6_acl_list if self.inputs.vrf not in acl["configuredVrfs"] or self.inputs.vrf not in acl["activeVrfs"]] + + if acl_not_configured: + self.result.is_failure(f"SNMP IPv6 ACL(s) not configured or active in vrf {self.inputs.vrf}: {acl_not_configured}") else: self.result.is_success() class VerifySnmpLocation(AntaTest): - """ - This class verifies the SNMP location of a device. + """Verifies the SNMP location of a device. Expected results: - * success: The test will pass if the SNMP location matches the provided input. - * failure: The test will fail if the SNMP location does not match the provided input. + * Success: The test will pass if the SNMP location matches the provided input. + * Failure: The test will fail if the SNMP location does not match the provided input. """ name = "VerifySnmpLocation" description = "Verifies the SNMP location of a device." - categories = ["snmp"] - commands = [AntaCommand(command="show snmp")] + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp")] class Input(AntaTest.Input): - """Defines the input parameters for this test case.""" + """Input model for the VerifySnmpLocation test.""" location: str """Expected SNMP location of the device.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySnmpLocation.""" location = self.instance_commands[0].json_output["location"]["location"] if location != self.inputs.location: @@ -147,27 +154,27 @@ def test(self) -> None: class VerifySnmpContact(AntaTest): - """ - This class verifies the SNMP contact of a device. + """Verifies the SNMP contact of a device. Expected results: - * success: The test will pass if the SNMP contact matches the provided input. - * failure: The test will fail if the SNMP contact does not match the provided input. + * Success: The test will pass if the SNMP contact matches the provided input. + * Failure: The test will fail if the SNMP contact does not match the provided input. """ name = "VerifySnmpContact" description = "Verifies the SNMP contact of a device." - categories = ["snmp"] - commands = [AntaCommand(command="show snmp")] + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp")] class Input(AntaTest.Input): - """Defines the input parameters for this test case.""" + """Input model for the VerifySnmpContact test.""" contact: str """Expected SNMP contact details of the device.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySnmpContact.""" contact = self.instance_commands[0].json_output["contact"]["contact"] if contact != self.inputs.contact: diff --git a/anta/tests/software.py b/anta/tests/software.py index a75efc51d..5282682d2 100644 --- a/anta/tests/software.py +++ b/anta/tests/software.py @@ -1,35 +1,42 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to the EOS software -""" +"""Module related to the EOS software tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -# Need to keep List for pydantic in python 3.8 -from typing import List +from typing import TYPE_CHECKING, ClassVar from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyEOSVersion(AntaTest): - """ - Verifies the device is running one of the allowed EOS version. + """Verifies that the device is running one of the allowed EOS version. + + Expected Results: + * Success: The test will pass if the device is running one of the allowed EOS version. + * Failure: The test will fail if the device is not running one of the allowed EOS version. """ name = "VerifyEOSVersion" - description = "Verifies the device is running one of the allowed EOS version." - categories = ["software"] - commands = [AntaCommand(command="show version")] + description = "Verifies the EOS version of the device." + categories: ClassVar[list[str]] = ["software"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version")] + + class Input(AntaTest.Input): + """Input model for the VerifyEOSVersion test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - versions: List[str] - """List of allowed EOS versions""" + versions: list[str] + """List of allowed EOS versions.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyEOSVersion.""" command_output = self.instance_commands[0].json_output if command_output["version"] in self.inputs.versions: self.result.is_success() @@ -38,21 +45,27 @@ def test(self) -> None: class VerifyTerminAttrVersion(AntaTest): - """ - Verifies the device is running one of the allowed TerminAttr version. + """Verifies that he device is running one of the allowed TerminAttr version. + + Expected Results: + * Success: The test will pass if the device is running one of the allowed TerminAttr version. + * Failure: The test will fail if the device is not running one of the allowed TerminAttr version. """ name = "VerifyTerminAttrVersion" - description = "Verifies the device is running one of the allowed TerminAttr version." - categories = ["software"] - commands = [AntaCommand(command="show version detail")] + description = "Verifies the TerminAttr version of the device." + categories: ClassVar[list[str]] = ["software"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version detail")] + + class Input(AntaTest.Input): + """Input model for the VerifyTerminAttrVersion test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - versions: List[str] - """List of allowed TerminAttr versions""" + versions: list[str] + """List of allowed TerminAttr versions.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyTerminAttrVersion.""" command_output = self.instance_commands[0].json_output command_output_data = command_output["details"]["packages"]["TerminAttr-core"]["version"] if command_output_data in self.inputs.versions: @@ -62,17 +75,21 @@ def test(self) -> None: class VerifyEOSExtensions(AntaTest): - """ - Verifies all EOS extensions installed on the device are enabled for boot persistence. + """Verifies that all EOS extensions installed on the device are enabled for boot persistence. + + Expected Results: + * Success: The test will pass if all EOS extensions installed on the device are enabled for boot persistence. + * Failure: The test will fail if some EOS extensions installed on the device are not enabled for boot persistence. """ name = "VerifyEOSExtensions" - description = "Verifies all EOS extensions installed on the device are enabled for boot persistence." - categories = ["software"] - commands = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")] + description = "Verifies that all EOS extensions installed on the device are enabled for boot persistence." + categories: ClassVar[list[str]] = ["software"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show extensions"), AntaCommand(command="show boot-extensions")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyEOSExtensions.""" boot_extensions = [] show_extensions_command_output = self.instance_commands[0].json_output show_boot_extensions_command_output = self.instance_commands[1].json_output @@ -80,9 +97,9 @@ def test(self) -> None: extension for extension, extension_data in show_extensions_command_output["extensions"].items() if extension_data["status"] == "installed" ] for extension in show_boot_extensions_command_output["extensions"]: - extension = extension.strip("\n") - if extension != "": - boot_extensions.append(extension) + formatted_extension = extension.strip("\n") + if formatted_extension != "": + boot_extensions.append(formatted_extension) installed_extensions.sort() boot_extensions.sort() if installed_extensions == boot_extensions: diff --git a/anta/tests/stp.py b/anta/tests/stp.py index 66f303b98..85f0c835f 100644 --- a/anta/tests/stp.py +++ b/anta/tests/stp.py @@ -1,15 +1,15 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to various Spanning Tree Protocol (STP) settings -""" +"""Module related to various Spanning Tree Protocol (STP) tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations -# Need to keep List for pydantic in python 3.8 -from typing import List, Literal +from typing import ClassVar, Literal + +from pydantic import Field from anta.custom_types import Vlan from anta.models import AntaCommand, AntaTemplate, AntaTest @@ -17,30 +17,33 @@ class VerifySTPMode(AntaTest): - """ - Verifies the configured STP mode for a provided list of VLAN(s). + """Verifies the configured STP mode for a provided list of VLAN(s). Expected Results: - * success: The test will pass if the STP mode is configured properly in the specified VLAN(s). - * failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s). + * Success: The test will pass if the STP mode is configured properly in the specified VLAN(s). + * Failure: The test will fail if the STP mode is NOT configured properly for one or more specified VLAN(s). """ name = "VerifySTPMode" description = "Verifies the configured STP mode for a provided list of VLAN(s)." - categories = ["stp"] - commands = [AntaTemplate(template="show spanning-tree vlan {vlan}")] + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree vlan {vlan}")] + + class Input(AntaTest.Input): + """Input model for the VerifySTPMode test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring mode: Literal["mstp", "rstp", "rapidPvst"] = "mstp" - """STP mode to verify""" - vlans: List[Vlan] - """List of VLAN on which to verify STP mode""" + """STP mode to verify. Supported values: mstp, rstp, rapidPvst. Defaults to mstp.""" + vlans: list[Vlan] + """List of VLAN on which to verify STP mode.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each VLAN in the input list.""" return [template.render(vlan=vlan) for vlan in self.inputs.vlans] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySTPMode.""" not_configured = [] wrong_stp_mode = [] for command in self.instance_commands: @@ -59,21 +62,21 @@ def test(self) -> None: class VerifySTPBlockedPorts(AntaTest): - """ - Verifies there is no STP blocked ports. + """Verifies there is no STP blocked ports. Expected Results: - * success: The test will pass if there are NO ports blocked by STP. - * failure: The test will fail if there are ports blocked by STP. + * Success: The test will pass if there are NO ports blocked by STP. + * Failure: The test will fail if there are ports blocked by STP. """ name = "VerifySTPBlockedPorts" description = "Verifies there is no STP blocked ports." - categories = ["stp"] - commands = [AntaCommand(command="show spanning-tree blockedports")] + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree blockedports")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySTPBlockedPorts.""" command_output = self.instance_commands[0].json_output if not (stp_instances := command_output["spanningTreeInstances"]): self.result.is_success() @@ -84,21 +87,21 @@ def test(self) -> None: class VerifySTPCounters(AntaTest): - """ - Verifies there is no errors in STP BPDU packets. + """Verifies there is no errors in STP BPDU packets. Expected Results: - * success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP. - * failure: The test will fail if there are STP BPDU packet errors on one or many interface(s). + * Success: The test will pass if there are NO STP BPDU packet errors under all interfaces participating in STP. + * Failure: The test will fail if there are STP BPDU packet errors on one or many interface(s). """ name = "VerifySTPCounters" description = "Verifies there is no errors in STP BPDU packets." - categories = ["stp"] - commands = [AntaCommand(command="show spanning-tree counters")] + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree counters")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySTPCounters.""" command_output = self.instance_commands[0].json_output interfaces_with_errors = [ interface for interface, counters in command_output["interfaces"].items() if counters["bpduTaggedError"] or counters["bpduOtherError"] != 0 @@ -110,28 +113,31 @@ def test(self) -> None: class VerifySTPForwardingPorts(AntaTest): - """ - Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s). + """Verifies that all interfaces are in a forwarding state for a provided list of VLAN(s). Expected Results: - * success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s). - * failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s). + * Success: The test will pass if all interfaces are in a forwarding state for the specified VLAN(s). + * Failure: The test will fail if one or many interfaces are NOT in a forwarding state in the specified VLAN(s). """ name = "VerifySTPForwardingPorts" description = "Verifies that all interfaces are forwarding for a provided list of VLAN(s)." - categories = ["stp"] - commands = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")] + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show spanning-tree topology vlan {vlan} status")] + + class Input(AntaTest.Input): + """Input model for the VerifySTPForwardingPorts test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - vlans: List[Vlan] - """List of VLAN on which to verify forwarding states""" + vlans: list[Vlan] + """List of VLAN on which to verify forwarding states.""" def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render the template for each VLAN in the input list.""" return [template.render(vlan=vlan) for vlan in self.inputs.vlans] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySTPForwardingPorts.""" not_configured = [] not_forwarding = [] for command in self.instance_commands: @@ -154,33 +160,35 @@ def test(self) -> None: class VerifySTPRootPriority(AntaTest): - """ - Verifies the STP root priority for a provided list of VLAN or MST instance ID(s). + """Verifies the STP root priority for a provided list of VLAN or MST instance ID(s). Expected Results: - * success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s). - * failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s). + * Success: The test will pass if the STP root priority is configured properly for the specified VLAN or MST instance ID(s). + * Failure: The test will fail if the STP root priority is NOT configured properly for the specified VLAN or MST instance ID(s). """ name = "VerifySTPRootPriority" description = "Verifies the STP root priority for a provided list of VLAN or MST instance ID(s)." - categories = ["stp"] - commands = [AntaCommand(command="show spanning-tree root detail")] + categories: ClassVar[list[str]] = ["stp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show spanning-tree root detail")] + + class Input(AntaTest.Input): + """Input model for the VerifySTPRootPriority test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring priority: int - """STP root priority to verify""" - instances: List[Vlan] = [] + """STP root priority to verify.""" + instances: list[Vlan] = Field(default=[]) """List of VLAN or MST instance ID(s). If empty, ALL VLAN or MST instance ID(s) will be verified.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifySTPRootPriority.""" command_output = self.instance_commands[0].json_output if not (stp_instances := command_output["instances"]): self.result.is_failure("No STP instances configured") return # Checking the type of instances based on first instance - first_name = list(stp_instances)[0] + first_name = next(iter(stp_instances)) if first_name.startswith("MST"): prefix = "MST" elif first_name.startswith("VL"): diff --git a/anta/tests/system.py b/anta/tests/system.py index 02ba09ed2..4c849da8a 100644 --- a/anta/tests/system.py +++ b/anta/tests/system.py @@ -1,40 +1,48 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to system-level features and protocols -""" +"""Module related to system-level features and protocols tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations import re +from typing import TYPE_CHECKING, ClassVar -from pydantic import conint - +from anta.custom_types import PositiveInteger from anta.models import AntaCommand, AntaTest +if TYPE_CHECKING: + from anta.models import AntaTemplate + +CPU_IDLE_THRESHOLD = 25 +MEMORY_THRESHOLD = 0.25 +DISK_SPACE_THRESHOLD = 75 + class VerifyUptime(AntaTest): - """ - This test verifies if the device uptime is higher than the provided minimum uptime value. + """Verifies if the device uptime is higher than the provided minimum uptime value. Expected Results: - * success: The test will pass if the device uptime is higher than the provided value. - * failure: The test will fail if the device uptime is lower than the provided value. + * Success: The test will pass if the device uptime is higher than the provided value. + * Failure: The test will fail if the device uptime is lower than the provided value. """ name = "VerifyUptime" description = "Verifies the device uptime." - categories = ["system"] - commands = [AntaCommand(command="show uptime")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show uptime")] + + class Input(AntaTest.Input): + """Input model for the VerifyUptime test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - minimum: conint(ge=0) # type: ignore - """Minimum uptime in seconds""" + minimum: PositiveInteger + """Minimum uptime in seconds.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyUptime.""" command_output = self.instance_commands[0].json_output if command_output["upTime"] > self.inputs.minimum: self.result.is_success() @@ -43,24 +51,24 @@ def test(self) -> None: class VerifyReloadCause(AntaTest): - """ - This test verifies the last reload cause of the device. + """Verifies the last reload cause of the device. Expected results: - * success: The test will pass if there are NO reload causes or if the last reload was caused by the user or after an FPGA upgrade. - * failure: The test will fail if the last reload was NOT caused by the user or after an FPGA upgrade. - * error: The test will report an error if the reload cause is NOT available. + * Success: The test will pass if there are NO reload causes or if the last reload was caused by the user or after an FPGA upgrade. + * Failure: The test will fail if the last reload was NOT caused by the user or after an FPGA upgrade. + * Error: The test will report an error if the reload cause is NOT available. """ name = "VerifyReloadCause" description = "Verifies the last reload cause of the device." - categories = ["system"] - commands = [AntaCommand(command="show reload cause")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show reload cause")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyReloadCause.""" command_output = self.instance_commands[0].json_output - if "resetCauses" not in command_output.keys(): + if "resetCauses" not in command_output: self.result.is_error(message="No reload causes available") return if len(command_output["resetCauses"]) == 0: @@ -79,24 +87,26 @@ def test(self) -> None: class VerifyCoredump(AntaTest): - """ - This test verifies if there are core dump files in the /var/core directory. + """Verifies if there are core dump files in the /var/core directory. Expected Results: - * success: The test will pass if there are NO core dump(s) in /var/core. - * failure: The test will fail if there are core dump(s) in /var/core. + * Success: The test will pass if there are NO core dump(s) in /var/core. + * Failure: The test will fail if there are core dump(s) in /var/core. Note: + ---- * This test will NOT check for minidump(s) generated by certain agents in /var/core/minidump. + """ name = "VerifyCoredump" description = "Verifies there are no core dump files." - categories = ["system"] - commands = [AntaCommand(command="show system coredump", ofmt="json")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show system coredump", ofmt="json")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyCoredump.""" command_output = self.instance_commands[0].json_output core_files = command_output["coreFiles"] if "minidump" in core_files: @@ -108,21 +118,21 @@ def test(self) -> None: class VerifyAgentLogs(AntaTest): - """ - This test verifies that no agent crash reports are present on the device. + """Verifies that no agent crash reports are present on the device. Expected Results: - * success: The test will pass if there is NO agent crash reported. - * failure: The test will fail if any agent crashes are reported. + * Success: The test will pass if there is NO agent crash reported. + * Failure: The test will fail if any agent crashes are reported. """ name = "VerifyAgentLogs" description = "Verifies there are no agent crash reports." - categories = ["system"] - commands = [AntaCommand(command="show agent logs crash", ofmt="text")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show agent logs crash", ofmt="text")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyAgentLogs.""" command_output = self.instance_commands[0].text_output if len(command_output) == 0: self.result.is_success() @@ -133,92 +143,92 @@ def test(self) -> None: class VerifyCPUUtilization(AntaTest): - """ - This test verifies whether the CPU utilization is below 75%. + """Verifies whether the CPU utilization is below 75%. Expected Results: - * success: The test will pass if the CPU utilization is below 75%. - * failure: The test will fail if the CPU utilization is over 75%. + * Success: The test will pass if the CPU utilization is below 75%. + * Failure: The test will fail if the CPU utilization is over 75%. """ name = "VerifyCPUUtilization" description = "Verifies whether the CPU utilization is below 75%." - categories = ["system"] - commands = [AntaCommand(command="show processes top once")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show processes top once")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyCPUUtilization.""" command_output = self.instance_commands[0].json_output command_output_data = command_output["cpuInfo"]["%Cpu(s)"]["idle"] - if command_output_data > 25: + if command_output_data > CPU_IDLE_THRESHOLD: self.result.is_success() else: self.result.is_failure(f"Device has reported a high CPU utilization: {100 - command_output_data}%") class VerifyMemoryUtilization(AntaTest): - """ - This test verifies whether the memory utilization is below 75%. + """Verifies whether the memory utilization is below 75%. Expected Results: - * success: The test will pass if the memory utilization is below 75%. - * failure: The test will fail if the memory utilization is over 75%. + * Success: The test will pass if the memory utilization is below 75%. + * Failure: The test will fail if the memory utilization is over 75%. """ name = "VerifyMemoryUtilization" description = "Verifies whether the memory utilization is below 75%." - categories = ["system"] - commands = [AntaCommand(command="show version")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyMemoryUtilization.""" command_output = self.instance_commands[0].json_output memory_usage = command_output["memFree"] / command_output["memTotal"] - if memory_usage > 0.25: + if memory_usage > MEMORY_THRESHOLD: self.result.is_success() else: self.result.is_failure(f"Device has reported a high memory usage: {(1 - memory_usage)*100:.2f}%") class VerifyFileSystemUtilization(AntaTest): - """ - This test verifies that no partition is utilizing more than 75% of its disk space. + """Verifies that no partition is utilizing more than 75% of its disk space. Expected Results: - * success: The test will pass if all partitions are using less than 75% of its disk space. - * failure: The test will fail if any partitions are using more than 75% of its disk space. + * Success: The test will pass if all partitions are using less than 75% of its disk space. + * Failure: The test will fail if any partitions are using more than 75% of its disk space. """ name = "VerifyFileSystemUtilization" description = "Verifies that no partition is utilizing more than 75% of its disk space." - categories = ["system"] - commands = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="bash timeout 10 df -h", ofmt="text")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyFileSystemUtilization.""" command_output = self.instance_commands[0].text_output self.result.is_success() for line in command_output.split("\n")[1:]: - if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > 75: + if "loop" not in line and len(line) > 0 and (percentage := int(line.split()[4].replace("%", ""))) > DISK_SPACE_THRESHOLD: self.result.is_failure(f"Mount point {line} is higher than 75%: reported {percentage}%") class VerifyNTP(AntaTest): - """ - This test verifies that the Network Time Protocol (NTP) is synchronized. + """Verifies that the Network Time Protocol (NTP) is synchronized. Expected Results: - * success: The test will pass if the NTP is synchronised. - * failure: The test will fail if the NTP is NOT synchronised. + * Success: The test will pass if the NTP is synchronised. + * Failure: The test will fail if the NTP is NOT synchronised. """ name = "VerifyNTP" description = "Verifies if NTP is synchronised." - categories = ["system"] - commands = [AntaCommand(command="show ntp status", ofmt="text")] + categories: ClassVar[list[str]] = ["system"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ntp status", ofmt="text")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyNTP.""" command_output = self.instance_commands[0].text_output if command_output.split("\n")[0].split(" ")[0] == "synchronised": self.result.is_success() diff --git a/anta/tests/vlan.py b/anta/tests/vlan.py index 58c28b6b7..0add6d157 100644 --- a/anta/tests/vlan.py +++ b/anta/tests/vlan.py @@ -1,24 +1,25 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to VLAN -""" +"""Module related to VLAN tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined +from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, ClassVar, Literal from anta.custom_types import Vlan from anta.models import AntaCommand, AntaTest from anta.tools.get_value import get_value from anta.tools.utils import get_failed_logs +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyVlanInternalPolicy(AntaTest): - """ - This class checks if the VLAN internal allocation policy is ascending or descending and - if the VLANs are within the specified range. + """Verifies if the VLAN internal allocation policy is ascending or descending and if the VLANs are within the specified range. Expected Results: * Success: The test will pass if the VLAN internal allocation policy is either ascending or descending @@ -28,15 +29,15 @@ class VerifyVlanInternalPolicy(AntaTest): """ name = "VerifyVlanInternalPolicy" - description = "This test checks the VLAN internal allocation policy and the range of VLANs." - categories = ["vlan"] - commands = [AntaCommand(command="show vlan internal allocation policy")] + description = "Verifies the VLAN internal allocation policy and the range of VLANs." + categories: ClassVar[list[str]] = ["vlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan internal allocation policy")] class Input(AntaTest.Input): - """Inputs for the VerifyVlanInternalPolicy test.""" + """Input model for the VerifyVlanInternalPolicy test.""" policy: Literal["ascending", "descending"] - """The VLAN internal allocation policy.""" + """The VLAN internal allocation policy. Supported values: ascending, descending.""" start_vlan_id: Vlan """The starting VLAN ID in the range.""" end_vlan_id: Vlan @@ -44,6 +45,7 @@ class Input(AntaTest.Input): @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyVlanInternalPolicy.""" command_output = self.instance_commands[0].json_output keys_to_verify = ["policy", "startVlanId", "endVlanId"] diff --git a/anta/tests/vxlan.py b/anta/tests/vxlan.py index e763b8f23..691d84f5e 100644 --- a/anta/tests/vxlan.py +++ b/anta/tests/vxlan.py @@ -1,16 +1,14 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test functions related to VXLAN -""" +"""Module related to VXLAN tests.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined +from __future__ import annotations from ipaddress import IPv4Address - -# Need to keep List and Dict for pydantic in python 3.8 -from typing import Dict, List +from typing import TYPE_CHECKING, ClassVar from pydantic import Field @@ -18,27 +16,30 @@ from anta.models import AntaCommand, AntaTest from anta.tools.get_value import get_value +if TYPE_CHECKING: + from anta.models import AntaTemplate + class VerifyVxlan1Interface(AntaTest): - """ - This test verifies if the Vxlan1 interface is configured and 'up/up'. + """Verifies if the Vxlan1 interface is configured and 'up/up'. !!! warning The name of this test has been updated from 'VerifyVxlan' for better representation. Expected Results: - * success: The test will pass if the Vxlan1 interface is configured with line protocol status and interface status 'up'. - * failure: The test will fail if the Vxlan1 interface line protocol status or interface status are not 'up'. - * skipped: The test will be skipped if the Vxlan1 interface is not configured. + * Success: The test will pass if the Vxlan1 interface is configured with line protocol status and interface status 'up'. + * Failure: The test will fail if the Vxlan1 interface line protocol status or interface status are not 'up'. + * Skipped: The test will be skipped if the Vxlan1 interface is not configured. """ name = "VerifyVxlan1Interface" description = "Verifies the Vxlan1 interface status." - categories = ["vxlan"] - commands = [AntaCommand(command="show interfaces description", ofmt="json")] + categories: ClassVar[list[str]] = ["vxlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces description", ofmt="json")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyVxlan1Interface.""" command_output = self.instance_commands[0].json_output if "Vxlan1" not in command_output["interfaceDescriptions"]: self.result.is_skipped("Vxlan1 interface is not configured") @@ -50,27 +51,27 @@ def test(self) -> None: else: self.result.is_failure( f"Vxlan1 interface is {command_output['interfaceDescriptions']['Vxlan1']['lineProtocolStatus']}" - f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}" + f"/{command_output['interfaceDescriptions']['Vxlan1']['interfaceStatus']}", ) class VerifyVxlanConfigSanity(AntaTest): - """ - This test verifies that no issues are detected with the VXLAN configuration. + """Verifies that no issues are detected with the VXLAN configuration. Expected Results: - * success: The test will pass if no issues are detected with the VXLAN configuration. - * failure: The test will fail if issues are detected with the VXLAN configuration. - * skipped: The test will be skipped if VXLAN is not configured on the device. + * Success: The test will pass if no issues are detected with the VXLAN configuration. + * Failure: The test will fail if issues are detected with the VXLAN configuration. + * Skipped: The test will be skipped if VXLAN is not configured on the device. """ name = "VerifyVxlanConfigSanity" description = "Verifies there are no VXLAN config-sanity inconsistencies." - categories = ["vxlan"] - commands = [AntaCommand(command="show vxlan config-sanity", ofmt="json")] + categories: ClassVar[list[str]] = ["vxlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan config-sanity", ofmt="json")] @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyVxlanConfigSanity.""" command_output = self.instance_commands[0].json_output if "categories" not in command_output or len(command_output["categories"]) == 0: self.result.is_skipped("VXLAN is not configured") @@ -87,26 +88,28 @@ def test(self) -> None: class VerifyVxlanVniBinding(AntaTest): - """ - This test verifies the VNI-VLAN bindings of the Vxlan1 interface. + """Verifies the VNI-VLAN bindings of the Vxlan1 interface. Expected Results: - * success: The test will pass if the VNI-VLAN bindings provided are properly configured. - * failure: The test will fail if any VNI lacks bindings or if any bindings are incorrect. - * skipped: The test will be skipped if the Vxlan1 interface is not configured. + * Success: The test will pass if the VNI-VLAN bindings provided are properly configured. + * Failure: The test will fail if any VNI lacks bindings or if any bindings are incorrect. + * Skipped: The test will be skipped if the Vxlan1 interface is not configured. """ name = "VerifyVxlanVniBinding" description = "Verifies the VNI-VLAN bindings of the Vxlan1 interface." - categories = ["vxlan"] - commands = [AntaCommand(command="show vxlan vni", ofmt="json")] + categories: ClassVar[list[str]] = ["vxlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vni", ofmt="json")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - bindings: Dict[Vni, Vlan] - """VNI to VLAN bindings to verify""" + class Input(AntaTest.Input): + """Input model for the VerifyVxlanVniBinding test.""" + + bindings: dict[Vni, Vlan] + """VNI to VLAN bindings to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyVxlanVniBinding.""" self.result.is_success() no_binding = [] @@ -117,17 +120,17 @@ def test(self) -> None: return for vni, vlan in self.inputs.bindings.items(): - vni = str(vni) - if vni in vxlan1["vniBindings"]: - retrieved_vlan = vxlan1["vniBindings"][vni]["vlan"] - elif vni in vxlan1["vniBindingsToVrf"]: - retrieved_vlan = vxlan1["vniBindingsToVrf"][vni]["vlan"] + str_vni = str(vni) + if str_vni in vxlan1["vniBindings"]: + retrieved_vlan = vxlan1["vniBindings"][str_vni]["vlan"] + elif str_vni in vxlan1["vniBindingsToVrf"]: + retrieved_vlan = vxlan1["vniBindingsToVrf"][str_vni]["vlan"] else: - no_binding.append(vni) + no_binding.append(str_vni) retrieved_vlan = None if retrieved_vlan and vlan != retrieved_vlan: - wrong_binding.append({vni: retrieved_vlan}) + wrong_binding.append({str_vni: retrieved_vlan}) if no_binding: self.result.is_failure(f"The following VNI(s) have no binding: {no_binding}") @@ -137,26 +140,28 @@ def test(self) -> None: class VerifyVxlanVtep(AntaTest): - """ - This test verifies the VTEP peers of the Vxlan1 interface. + """Verifies the VTEP peers of the Vxlan1 interface. Expected Results: - * success: The test will pass if all provided VTEP peers are identified and matching. - * failure: The test will fail if any VTEP peer is missing or there are unexpected VTEP peers. - * skipped: The test will be skipped if the Vxlan1 interface is not configured. + * Success: The test will pass if all provided VTEP peers are identified and matching. + * Failure: The test will fail if any VTEP peer is missing or there are unexpected VTEP peers. + * Skipped: The test will be skipped if the Vxlan1 interface is not configured. """ name = "VerifyVxlanVtep" description = "Verifies the VTEP peers of the Vxlan1 interface" - categories = ["vxlan"] - commands = [AntaCommand(command="show vxlan vtep", ofmt="json")] + categories: ClassVar[list[str]] = ["vxlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vxlan vtep", ofmt="json")] - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring - vteps: List[IPv4Address] - """List of VTEP peers to verify""" + class Input(AntaTest.Input): + """Input model for the VerifyVxlanVtep test.""" + + vteps: list[IPv4Address] + """List of VTEP peers to verify.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyVxlanVtep.""" self.result.is_success() inputs_vteps = [str(input_vtep) for input_vtep in self.inputs.vteps] @@ -176,30 +181,30 @@ def test(self) -> None: class VerifyVxlan1ConnSettings(AntaTest): - """ - Verifies the interface vxlan1 source interface and UDP port. + """Verifies the interface vxlan1 source interface and UDP port. Expected Results: - * success: Passes if the interface vxlan1 source interface and UDP port are correct. - * failure: Fails if the interface vxlan1 source interface or UDP port are incorrect. - * skipped: Skips if the Vxlan1 interface is not configured. + * Success: Passes if the interface vxlan1 source interface and UDP port are correct. + * Failure: Fails if the interface vxlan1 source interface or UDP port are incorrect. + * Skipped: Skips if the Vxlan1 interface is not configured. """ name = "VerifyVxlan1ConnSettings" description = "Verifies the interface vxlan1 source interface and UDP port." - categories = ["vxlan"] - commands = [AntaCommand(command="show interfaces")] + categories: ClassVar[list[str]] = ["vxlan"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show interfaces")] class Input(AntaTest.Input): - """Inputs for the VerifyVxlan1ConnSettings test.""" + """Input model for the VerifyVxlan1ConnSettings test.""" source_interface: VxlanSrcIntf - """Source loopback interface of vxlan1 interface""" + """Source loopback interface of vxlan1 interface.""" udp_port: int = Field(ge=1024, le=65335) - """UDP port used for vxlan1 interface""" + """UDP port used for vxlan1 interface.""" @AntaTest.anta_test def test(self) -> None: + """Main test function for VerifyVxlan1ConnSettings.""" self.result.is_success() command_output = self.instance_commands[0].json_output diff --git a/anta/tools/__init__.py b/anta/tools/__init__.py index e772bee41..7a69f78d9 100644 --- a/anta/tools/__init__.py +++ b/anta/tools/__init__.py @@ -1,3 +1,7 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""anta.tools submodule. + +Utilities for ANTA codebase. +""" diff --git a/anta/tools/get_dict_superset.py b/anta/tools/get_dict_superset.py index b3bbde0b9..a0f5190d0 100644 --- a/anta/tools/get_dict_superset.py +++ b/anta/tools/get_dict_superset.py @@ -3,18 +3,20 @@ # that can be found in the LICENSE file. """Get one dictionary from a list of dictionaries by matching the given key and values.""" + from __future__ import annotations -from typing import Any, Optional +from typing import Any def get_dict_superset( list_of_dicts: list[dict[Any, Any]], input_dict: dict[Any, Any], - default: Optional[Any] = None, + default: Any | None = None, + var_name: str | None = None, + custom_error_msg: str | None = None, + *, required: bool = False, - var_name: Optional[str] = None, - custom_error_msg: Optional[str] = None, ) -> Any: """Get the first dictionary from a list of dictionaries that is a superset of the input dict. @@ -46,6 +48,7 @@ def get_dict_superset( ------ ValueError If the keys and values are not found and "required" == True + """ if not isinstance(list_of_dicts, list) or not list_of_dicts or not isinstance(input_dict, dict) or not input_dict: if required: diff --git a/anta/tools/get_item.py b/anta/tools/get_item.py index db5695b6b..4bb0e5bad 100644 --- a/anta/tools/get_item.py +++ b/anta/tools/get_item.py @@ -3,9 +3,10 @@ # that can be found in the LICENSE file. """Get one dictionary from a list of dictionaries by matching the given key and value.""" + from __future__ import annotations -from typing import Any, Optional +from typing import Any # pylint: disable=too-many-arguments @@ -13,11 +14,12 @@ def get_item( list_of_dicts: list[dict[Any, Any]], key: Any, value: Any, - default: Optional[Any] = None, + default: Any | None = None, + var_name: str | None = None, + custom_error_msg: str | None = None, + *, required: bool = False, case_sensitive: bool = False, - var_name: Optional[str] = None, - custom_error_msg: Optional[str] = None, ) -> Any: """Get one dictionary from a list of dictionaries by matching the given key and value. @@ -53,6 +55,7 @@ def get_item( ------ ValueError If the key and value is not found and "required" == True + """ if var_name is None: var_name = key diff --git a/anta/tools/get_value.py b/anta/tools/get_value.py index 5e4b84dd9..963677efa 100644 --- a/anta/tools/get_value.py +++ b/anta/tools/get_value.py @@ -1,22 +1,29 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Get a value from a dictionary or nested dictionaries. -""" +"""Get a value from a dictionary or nested dictionaries.""" + from __future__ import annotations -from typing import Any, Optional +from typing import Any # pylint: disable=too-many-arguments def get_value( - dictionary: dict[Any, Any], key: str, default: Optional[Any] = None, required: bool = False, org_key: Optional[str] = None, separator: str = "." + dictionary: dict[Any, Any], + key: str, + default: Any | None = None, + org_key: str | None = None, + separator: str = ".", + *, + required: bool = False, ) -> Any: - """ - Get a value from a dictionary or nested dictionaries. + """Get a value from a dictionary or nested dictionaries. + Key supports dot-notation like "foo.bar" to do deeper lookups. + Returns the supplied default value or None if the key is not found and required is False. + Parameters ---------- dictionary : dict @@ -32,16 +39,18 @@ def get_value( separator: str String to use as the separator parameter in the split function. Useful in cases when the key can contain variables with "." inside (e.g. hostnames) + Returns ------- any Value or default value + Raises ------ ValueError - If the key is not found and required == True - """ + If the key is not found and required == True. + """ if org_key is None: org_key = key keys = key.split(separator) diff --git a/anta/tools/utils.py b/anta/tools/utils.py index e361d1e10..595caf41f 100644 --- a/anta/tools/utils.py +++ b/anta/tools/utils.py @@ -1,25 +1,27 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Toolkit for ANTA. -""" +"""Toolkit for ANTA.""" + from __future__ import annotations from typing import Any def get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any]) -> str: - """ - Get the failed log for a test. + """Get the failed log for a test. + Returns the failed log or an empty string if there is no difference between the expected and actual output. - Parameters: + Args: + ---- expected_output (dict): Expected output of a test. actual_output (dict): Actual output of a test Returns: + ------- str: Failed log of a test. + """ failed_logs = [] diff --git a/docs/README.md b/docs/README.md index d47d3fe6c..e4757f5bd 100755 --- a/docs/README.md +++ b/docs/README.md @@ -6,9 +6,10 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/arista-netdevops-community/anta/blob/main/LICENSE) [![Linting and Testing Anta](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml/badge.svg)](https://github.com/arista-netdevops-community/anta/actions/workflows/code-testing.yml) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/arista-netdevops-community/anta) [![github release](https://img.shields.io/github/release/arista-netdevops-community/anta.svg)](https://github.com/arista-netdevops-community/anta/releases/) +[![image](https://img.shields.io/pypi/v/anta.svg)](https://pypi.python.org/pypi/anta) ![PyPI - Downloads](https://img.shields.io/pypi/dm/anta) ![coverage](https://raw.githubusercontent.com/arista-netdevops-community/anta/coverage-badge/latest-release-coverage.svg) diff --git a/docs/scripts/generate_svg.py b/docs/scripts/generate_svg.py index 19177db57..426a78529 100644 --- a/docs/scripts/generate_svg.py +++ b/docs/scripts/generate_svg.py @@ -1,13 +1,16 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -A script to generate svg files from anta command +"""A script to generate svg files from anta command. usage: python generate_svg.py anta ... """ +# This script is not a package +# ruff: noqa: INP001 +# This script contains print statements +# ruff: noqa: T201 import io import os @@ -27,8 +30,7 @@ def custom_progress_bar() -> None: - """ - Set the console of progress_bar to main anta console + """Set the console of progress_bar to main anta console. Caveat: this capture all steps of the progress bar.. Disabling refresh to only capture beginning and end @@ -76,17 +78,15 @@ def custom_progress_bar() -> None: # Redirect stdout of the program towards another StringIO to capture help # that is not part or anta rich console - with redirect_stdout(io.StringIO()) as f: - # redirect potential progress bar output to console by patching - with patch("anta.cli.nrfu.commands.anta_progress_bar", custom_progress_bar): - with suppress(SystemExit): - function() + # redirect potential progress bar output to console by patching + with redirect_stdout(io.StringIO()) as f, patch("anta.cli.nrfu.commands.anta_progress_bar", custom_progress_bar), suppress(SystemExit): + function() # print to our new console the output of anta console new_console.print(console.export_text()) # print the content of the stdout to our new_console new_console.print(f.getvalue()) - filename = f"{'_'.join(map(lambda x: x.replace('/', '_').replace('-', '_').replace('.', '_'), args))}.svg" + filename = f"{'_'.join(x.replace('/', '_').replace('-', '_').replace('.', '_') for x in args)}.svg" filename = f"{OUTPUT_DIR}/{filename}" print(f"File saved at {filename}") new_console.save_svg(filename, title=" ".join(args)) diff --git a/examples/tests.yaml b/examples/tests.yaml index 0fdcb74dd..e56460c28 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -217,12 +217,12 @@ anta.tests.profiles: profile: vxlan-routing anta.tests.ptp: - - PtpModeStatus: - - PtpPortModeStatus: - - PtpLockStatus: - - PtpGMStatus: - validGM: 0xec:46:70:ff:fe:00:ff:a9 - - PtpOffset: + - VerifyPtpModeStatus: + - VerifyPtpPortModeStatus: + - VerifyPtpLockStatus: + - VerifyPtpGMStatus: + gmid: 0xec:46:70:ff:fe:00:ff:a9 + - VerifyPtpOffset: anta.tests.security: - VerifySSHStatus: diff --git a/pyproject.toml b/pyproject.toml index a7afbef02..8cdaab9dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,12 +22,12 @@ dependencies = [ "click~=8.1.6", "click-help-colors~=0.9", "cvprac~=1.3.1", - "pydantic>=2.1.1,<2.7.0", + "pydantic>=2.6.1,<2.7.0", "pydantic-extra-types>=2.1.0", + "eval-type-backport~=0.1.3", # Support newer typing features in older Python versions (required until Python 3.9 support is removed) "PyYAML~=6.0", "requests~=2.31.0", "rich>=13.5.2,<13.8.0", - "rich>=13.5.2,<13.8.0", "asyncssh>=2.13.2,<2.15.0", "Jinja2~=3.1.2", ] @@ -56,14 +56,11 @@ requires-python = ">=3.8" [project.optional-dependencies] dev = [ "bumpver==2023.1129", - "black==24.2.0", - "flake8==7.0.0", - "isort==5.13.2", "mypy~=1.9", "mypy-extensions~=1.0", "pre-commit>=3.3.3", "pylint>=2.17.5", - "ruff>=0.0.280", + "ruff~=0.3.2", "pytest>=7.4.0", "pytest-asyncio>=0.21.1", "pytest-cov>=4.1.0", @@ -125,21 +122,6 @@ push = false "docs/contribution.md" = ["anta {version}"] "docs/requirements-and-installation.md " = ["anta, version v{version}"] -################################ -# Linting -################################ -[tool.isort] -profile = "black" -line_length = 165 - -[tool.black] -line-length = 165 -force-exclude = """ -( -.*data.py| -) -""" - ################################ # Typing # mypy as per https://pydantic-docs.helpmanual.io/mypy_plugin/#enabling-the-plugin @@ -188,10 +170,10 @@ testpaths = ["tests"] branch = true source = ["anta"] parallel = true -omit = [ +omit= [ # omit aioeapi patch - "anta/aioeapi.py", - ] + "anta/aioeapi.py", +] [tool.coverage.report] # Regexes for lines to exclude from consideration @@ -259,11 +241,9 @@ commands = [testenv:lint] description = Check the code style commands = - black --check --diff --color . - isort --check --diff --color . - flake8 --max-line-length=165 --config=/dev/null anta tests - pylint anta - pylint tests + ruff check . + pylint --rcfile=pylintrc anta + pylint --rcfile=pylintrc tests [testenv:type] description = Check typing @@ -289,17 +269,7 @@ commands = depends = py311 """ -# TRYING RUFF - NOT ENABLED IN CI NOR PRE-COMMIT [tool.ruff] -# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -# select = ["E", "F"] -# select all cause we like being suffering -select = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] -ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] -unfixable = [] # Exclude a variety of commonly ignored directories. exclude = [ @@ -309,32 +279,119 @@ exclude = [ ".git", ".git-rewrite", ".hg", + ".ipynb_checkpoints", ".mypy_cache", ".nox", ".pants.d", + ".pyenv", + ".pytest_cache", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv", + ".vscode", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", + "site-packages", "venv", + ".github", + "aioeapi.py" # Remove this when https://github.com/jeremyschulman/aio-eapi/pull/13 is merged ] # Same as Black. line-length = 165 -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - # Assume Python 3.8 as this is the lowest supported version for ANTA target-version = "py38" -[tool.ruff.mccabe] +[tool.ruff.lint] +# select all cause we like being suffering +select = ["ALL"] +ignore = [ + "ANN101", # Missing type annotation for `self` in method - we know what self is.. + "D203", # Ignoring conflicting D* warnings - one-blank-line-before-class + "D213", # Ignoring conflicting D* warnings - multi-line-summary-second-line + "COM812", # Ignoring conflicting rules that may cause conflicts when used with the formatter + "ISC001", # Ignoring conflicting rules that may cause conflicts when used with the formatter + "TD002", # We don't have require authors in TODO + "TD003", # We don't have an issue link for all TODOs today + "FIX002", # Line contains TODO - ignoring for ruff for now +] + + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.lint.mccabe] # Unlike Flake8, default to a complexity level of 10. max-complexity = 10 + +[tool.ruff.lint.pep8-naming] +"ignore-names" = [ + "RICH_COLOR_PALETTE" +] + +[tool.ruff.lint.flake8-type-checking] +# These classes require that type annotations be available at runtime +runtime-evaluated-base-classes = ["pydantic.BaseModel", "anta.models.AntaTest.Input"] + + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "S101", # Complains about asserts in units and libs. + "SLF001", # Lots of private member accessed for test purposes +] +"tests/units/*" = [ + "BLE001", # Do not catch blind exception: `Exception` - already disabling this in pylint + "FBT001", # Boolean-typed positional argument in function definition + "PLR0913", # Too many arguments to function call (8 > 5) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "S105", # Passwords are indeed hardcoded in tests + "S106", # Passwords are indeed hardcoded in tests + "S108", # Probable insecure usage of temporary file or directory +] +"tests/units/anta_tests/test_interfaces.py" = [ + "S104", # False positive for 0.0.0.0 bindings in test inputs +] +"anta/*" = [ + "BLE001", # Do not catch blind exception: `Exception` - caught by other linter + "TRY400", # Use `logging.exception` instead of `logging.error` - we know what we are doing +] +"anta/cli/exec/utils.py" = [ + "SLF001", # TODO: some private members, lets try to fix +] +"anta/cli/*" = [ + "PLR0913", # Allow more than 5 input arguments in CLI functions + "ANN401", # TODO: Check if we can update the Any type hints in the CLI +] +"anta/tests/field_notices.py" = [ + "PLR2004", # Magic value used in comparison, consider replacing 2131 with a constant variable - Field notice IDs are magic values + "C901", # TODO: test function is too complex, needs a refactor + "PLR0911", # TODO: Too many return statements, same as above needs a refactor +] +"anta/decorators.py" = [ + "ANN401", # Ok to use Any type hint in our decorators +] +"anta/tools/get*.py" = [ + "ANN401", # Ok to use Any type hint in our custom get functions + "PLR0913", # Ok to have more than 5 arguments in our custom get functions +] +"anta/runner.py" = [ + "C901", # TODO: main function is too complex, needs a refactor + "PERF203", # TODO: try - except within a loop, same sa above needs a refactor +] +"anta/device.py" = [ + "PLR0913", # Ok to have more than 5 arguments in the AntaDevice classes +] +"anta/inventory/__init__.py" = [ + "PLR0913", # Ok to have more than 5 arguments in the AntaInventory class +] diff --git a/tests/__init__.py b/tests/__init__.py index e772bee41..0a2486a25 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Tests for ANTA.""" diff --git a/tests/conftest.py b/tests/conftest.py index 5a40c24f1..7aa229ced 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,15 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -conftest.py - used to store anta specific fixtures used for tests -""" +"""conftest.py - used to store anta specific fixtures used for tests.""" + from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any import pytest -if TYPE_CHECKING: - from pytest import Metafunc - # Load fixtures from dedicated file tests/lib/fixture.py # As well as pytest_asyncio plugin to test co-routines pytest_plugins = [ @@ -31,8 +27,7 @@ def build_test_id(val: dict[str, Any]) -> str: - """ - build id for a unit test of an AntaTest subclass + """Build id for a unit test of an AntaTest subclass. { "name": "meaniful test name", @@ -43,9 +38,9 @@ def build_test_id(val: dict[str, Any]) -> str: return f"{val['test'].__module__}.{val['test'].__name__}-{val['name']}" -def pytest_generate_tests(metafunc: Metafunc) -> None: - """ - This function is called during test collection. +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Generate ANTA testts unit tests dynamically during test collection. + It will parametrize test cases based on the `DATA` data structure defined in `tests.units.anta_tests` modules. See `tests/units/anta_tests/README.md` for more information on how to use it. Test IDs are generated using the `build_test_id` function above. diff --git a/tests/data/__init__.py b/tests/data/__init__.py index e772bee41..864da68bf 100644 --- a/tests/data/__init__.py +++ b/tests/data/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Data for unit tests.""" diff --git a/tests/data/json_data.py b/tests/data/json_data.py index ad2c9ed99..563084065 100644 --- a/tests/data/json_data.py +++ b/tests/data/json_data.py @@ -2,6 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. # pylint: skip-file +"""JSON Data for unit tests.""" INVENTORY_MODEL_HOST_VALID = [ {"name": "validIPv4", "input": "1.1.1.1", "expected_result": "valid"}, @@ -92,7 +93,7 @@ "ranges": [ {"start": "10.1.0.1", "end": "10.1.0.10"}, {"start": "10.2.0.1", "end": "10.2.1.10"}, - ] + ], }, "expected_result": "valid", }, @@ -150,8 +151,8 @@ "ranges": [ {"start": "10.0.0.1", "end": "10.0.0.11"}, {"start": "10.0.0.101", "end": "10.0.0.111"}, - ] - } + ], + }, }, "expected_result": "valid", "parameters": { @@ -197,8 +198,8 @@ "ranges": [ {"start": "10.0.0.1", "end": "10.0.0.11", "tags": ["leaf"]}, {"start": "10.0.0.101", "end": "10.0.0.111", "tags": ["spine"]}, - ] - } + ], + }, }, "expected_result": "valid", "parameters": { @@ -242,8 +243,8 @@ "ranges": [ {"start": "10.0.0.1", "end": "10.0.0.11"}, {"start": "10.0.0.100", "end": "10.0.0.111"}, - ] - } + ], + }, }, "expected_result": "invalid", }, diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py index e772bee41..cd54f3aac 100644 --- a/tests/lib/__init__.py +++ b/tests/lib/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Library for ANTA unit tests.""" diff --git a/tests/lib/anta.py b/tests/lib/anta.py index b97d91d47..d78cee776 100644 --- a/tests/lib/anta.py +++ b/tests/lib/anta.py @@ -1,20 +1,20 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -generic test funciton used to generate unit tests for each AntaTest -""" +"""generic test funciton used to generate unit tests for each AntaTest.""" + from __future__ import annotations import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any -from anta.device import AntaDevice +if TYPE_CHECKING: + from anta.device import AntaDevice def test(device: AntaDevice, data: dict[str, Any]) -> None: - """ - Generic test function for AntaTest subclass. + """Generic test function for AntaTest subclass. + See `tests/units/anta_tests/README.md` for more information on how to use it. """ # Instantiate the AntaTest subclass diff --git a/tests/lib/fixture.py b/tests/lib/fixture.py index 68e9e576c..2e6982073 100644 --- a/tests/lib/fixture.py +++ b/tests/lib/fixture.py @@ -1,28 +1,31 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Fixture for Anta Testing""" +"""Fixture for Anta Testing.""" + from __future__ import annotations import logging import shutil -from pathlib import Path -from typing import Any, Callable, Iterator +from typing import TYPE_CHECKING, Any, Callable, Iterator from unittest.mock import patch import pytest from click.testing import CliRunner, Result -from pytest import CaptureFixture from anta import aioeapi from anta.cli.console import console from anta.device import AntaDevice, AsyncEOSDevice from anta.inventory import AntaInventory -from anta.models import AntaCommand from anta.result_manager import ResultManager from anta.result_manager.models import TestResult from tests.lib.utils import default_anta_env +if TYPE_CHECKING: + from pathlib import Path + + from anta.models import AntaCommand + logger = logging.getLogger(__name__) DEVICE_HW_MODEL = "pytest" @@ -38,7 +41,11 @@ "clear counters": {}, "clear hardware counter drop": {}, "undefined": aioeapi.EapiCommandError( - passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[] + passed=[], + failed="show version", + errors=["Authorization denied for command 'show version'"], + errmsg="Invalid command", + not_exec=[], ), } @@ -50,11 +57,9 @@ } -@pytest.fixture +@pytest.fixture() def device(request: pytest.FixtureRequest) -> Iterator[AntaDevice]: - """ - Returns an AntaDevice instance with mocked abstract method - """ + """Return an AntaDevice instance with mocked abstract method.""" def _collect(command: AntaCommand) -> None: command.output = COMMAND_OUTPUT @@ -64,22 +69,21 @@ def _collect(command: AntaCommand) -> None: if hasattr(request, "param"): # Fixture is parametrized indirectly kwargs.update(request.param) - with patch.object(AntaDevice, "__abstractmethods__", set()): - with patch("anta.device.AntaDevice._collect", side_effect=_collect): - # AntaDevice constructor does not have hw_model argument - hw_model = kwargs.pop("hw_model") - dev = AntaDevice(**kwargs) # type: ignore[abstract, arg-type] # pylint: disable=abstract-class-instantiated, unexpected-keyword-arg - dev.hw_model = hw_model - yield dev + with patch.object(AntaDevice, "__abstractmethods__", set()), patch("anta.device.AntaDevice._collect", side_effect=_collect): + # AntaDevice constructor does not have hw_model argument + hw_model = kwargs.pop("hw_model") + dev = AntaDevice(**kwargs) # type: ignore[abstract, arg-type] # pylint: disable=abstract-class-instantiated, unexpected-keyword-arg + dev.hw_model = hw_model + yield dev -@pytest.fixture +@pytest.fixture() def test_inventory() -> AntaInventory: - """ - Return the test_inventory - """ + """Return the test_inventory.""" env = default_anta_env() - assert env["ANTA_INVENTORY"] and env["ANTA_USERNAME"] and env["ANTA_PASSWORD"] is not None + assert env["ANTA_INVENTORY"] + assert env["ANTA_USERNAME"] + assert env["ANTA_PASSWORD"] is not None return AntaInventory.parse( filename=env["ANTA_INVENTORY"], username=env["ANTA_USERNAME"], @@ -88,34 +92,30 @@ def test_inventory() -> AntaInventory: # tests.unit.test_device.py fixture -@pytest.fixture +@pytest.fixture() def async_device(request: pytest.FixtureRequest) -> AsyncEOSDevice: - """ - Returns an AsyncEOSDevice instance - """ - - kwargs = {"name": DEVICE_NAME, "host": "42.42.42.42", "username": "anta", "password": "anta"} + """Return an AsyncEOSDevice instance.""" + kwargs = { + "name": DEVICE_NAME, + "host": "42.42.42.42", + "username": "anta", + "password": "anta", + } if hasattr(request, "param"): # Fixture is parametrized indirectly kwargs.update(request.param) - dev = AsyncEOSDevice(**kwargs) # type: ignore[arg-type] - return dev + return AsyncEOSDevice(**kwargs) # type: ignore[arg-type] # tests.units.result_manager fixtures -@pytest.fixture +@pytest.fixture() def test_result_factory(device: AntaDevice) -> Callable[[int], TestResult]: - """ - Return a anta.result_manager.models.TestResult object - """ - + """Return a anta.result_manager.models.TestResult object.""" # pylint: disable=redefined-outer-name def _create(index: int = 0) -> TestResult: - """ - Actual Factory - """ + """Actual Factory.""" return TestResult( name=device.name, test=f"VerifyTest{index}", @@ -127,38 +127,25 @@ def _create(index: int = 0) -> TestResult: return _create -@pytest.fixture +@pytest.fixture() def list_result_factory(test_result_factory: Callable[[int], TestResult]) -> Callable[[int], list[TestResult]]: - """ - Return a list[TestResult] with 'size' TestResult instanciated using the test_result_factory fixture - """ - + """Return a list[TestResult] with 'size' TestResult instanciated using the test_result_factory fixture.""" # pylint: disable=redefined-outer-name def _factory(size: int = 0) -> list[TestResult]: - """ - Factory for list[TestResult] entry of size entries - """ - result: list[TestResult] = [] - for i in range(size): - result.append(test_result_factory(i)) - return result + """Create a factory for list[TestResult] entry of size entries.""" + return [test_result_factory(i) for i in range(size)] return _factory -@pytest.fixture +@pytest.fixture() def result_manager_factory(list_result_factory: Callable[[int], list[TestResult]]) -> Callable[[int], ResultManager]: - """ - Return a ResultManager factory that takes as input a number of tests - """ - + """Return a ResultManager factory that takes as input a number of tests.""" # pylint: disable=redefined-outer-name def _factory(number: int = 0) -> ResultManager: - """ - Factory for list[TestResult] entry of size entries - """ + """Create a factory for list[TestResult] entry of size entries.""" result_manager = ResultManager() result_manager.add_test_results(list_result_factory(number)) return result_manager @@ -167,10 +154,12 @@ def _factory(number: int = 0) -> ResultManager: # tests.units.cli fixtures -@pytest.fixture +@pytest.fixture() def temp_env(tmp_path: Path) -> dict[str, str | None]: - """Fixture that create a temporary ANTA inventory that can be overriden - and returns the corresponding environment variables""" + """Fixture that create a temporary ANTA inventory. + + The inventory can be overriden and returns the corresponding environment variables. + """ env = default_anta_env() anta_inventory = str(env["ANTA_INVENTORY"]) temp_inventory = tmp_path / "test_inventory.yml" @@ -179,16 +168,19 @@ def temp_env(tmp_path: Path) -> dict[str, str | None]: return env -@pytest.fixture -def click_runner(capsys: CaptureFixture[str]) -> Iterator[CliRunner]: - """ - Convenience fixture to return a click.CliRunner for cli testing - """ +@pytest.fixture() +# Disabling C901 - too complex as we like our runner like this +def click_runner(capsys: pytest.CaptureFixture[str]) -> Iterator[CliRunner]: # noqa: C901 + """Return a click.CliRunner for cli testing.""" class AntaCliRunner(CliRunner): - """Override CliRunner to inject specific variables for ANTA""" + """Override CliRunner to inject specific variables for ANTA.""" - def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def] + def invoke( + self, + *args: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> Result: # Inject default env if not provided kwargs["env"] = kwargs["env"] if "env" in kwargs else default_anta_env() # Deterministic terminal width @@ -198,14 +190,18 @@ def invoke(self, *args, **kwargs) -> Result: # type: ignore[no-untyped-def] # Way to fix https://github.com/pallets/click/issues/824 with capsys.disabled(): result = super().invoke(*args, **kwargs) - print("--- CLI Output ---") - print(result.output) + # disabling T201 as we want to print here + print("--- CLI Output ---") # noqa: T201 + print(result.output) # noqa: T201 return result def cli( - command: str | None = None, commands: list[dict[str, Any]] | None = None, ofmt: str = "json", version: int | str | None = "latest", **kwargs: Any + command: str | None = None, + commands: list[dict[str, Any]] | None = None, + ofmt: str = "json", + _version: int | str | None = "latest", + **_kwargs: Any, # noqa: ANN401 ) -> dict[str, Any] | list[dict[str, Any]]: - # pylint: disable=unused-argument def get_output(command: str | dict[str, Any]) -> dict[str, Any]: if isinstance(command, dict): command = command["cmd"] @@ -216,7 +212,7 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: mock_cli = MOCK_CLI_TEXT for mock_cmd, output in mock_cli.items(): if command == mock_cmd: - logger.info(f"Mocking command {mock_cmd}") + logger.info("Mocking command %s", mock_cmd) if isinstance(output, aioeapi.EapiCommandError): raise output return output @@ -226,17 +222,17 @@ def get_output(command: str | dict[str, Any]) -> dict[str, Any]: res: dict[str, Any] | list[dict[str, Any]] if command is not None: - logger.debug(f"Mock input {command}") + logger.debug("Mock input %s", command) res = get_output(command) if commands is not None: - logger.debug(f"Mock input {commands}") + logger.debug("Mock input %s", commands) res = list(map(get_output, commands)) - logger.debug(f"Mock output {res}") + logger.debug("Mock output %s", res) return res # Patch aioeapi methods used by AsyncEOSDevice. See tests/units/test_device.py with patch("aioeapi.device.Device.check_connection", return_value=True), patch("aioeapi.device.Device.cli", side_effect=cli), patch("asyncssh.connect"), patch( - "asyncssh.scp" + "asyncssh.scp", ): console._color_system = None # pylint: disable=protected-access yield AntaCliRunner() diff --git a/tests/lib/utils.py b/tests/lib/utils.py index 460e014dd..125593680 100644 --- a/tests/lib/utils.py +++ b/tests/lib/utils.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -tests.lib.utils -""" +"""tests.lib.utils.""" + from __future__ import annotations from pathlib import Path @@ -11,22 +10,17 @@ def generate_test_ids_dict(val: dict[str, Any], key: str = "name") -> str: - """ - generate_test_ids Helper to generate test ID for parametrize - """ + """generate_test_ids Helper to generate test ID for parametrize.""" return val.get(key, "unamed_test") def generate_test_ids_list(val: list[dict[str, Any]], key: str = "name") -> list[str]: - """ - generate_test_ids Helper to generate test ID for parametrize - """ - return [entry[key] if key in entry.keys() else "unamed_test" for entry in val] + """generate_test_ids Helper to generate test ID for parametrize.""" + return [entry.get(key, "unamed_test") for entry in val] def generate_test_ids(data: list[dict[str, Any]]) -> list[str]: - """ - build id for a unit test of an AntaTest subclass + """Build id for a unit test of an AntaTest subclass. { "name": "meaniful test name", @@ -38,9 +32,7 @@ def generate_test_ids(data: list[dict[str, Any]]) -> list[str]: def default_anta_env() -> dict[str, str | None]: - """ - Return a default_anta_environement which can be passed to a cliRunner.invoke method - """ + """Return a default_anta_environement which can be passed to a cliRunner.invoke method.""" return { "ANTA_USERNAME": "anta", "ANTA_PASSWORD": "formica", diff --git a/tests/units/__init__.py b/tests/units/__init__.py index e772bee41..6f96a0d1c 100644 --- a/tests/units/__init__.py +++ b/tests/units/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Unit tests for anta.""" diff --git a/tests/units/anta_tests/__init__.py b/tests/units/anta_tests/__init__.py index e772bee41..8ca0e8c7c 100644 --- a/tests/units/anta_tests/__init__.py +++ b/tests/units/anta_tests/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test for anta.tests submodule.""" diff --git a/tests/units/anta_tests/routing/__init__.py b/tests/units/anta_tests/routing/__init__.py index e772bee41..aef127484 100644 --- a/tests/units/anta_tests/routing/__init__.py +++ b/tests/units/anta_tests/routing/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test for anta.tests.routing submodule.""" diff --git a/tests/units/anta_tests/routing/test_bgp.py b/tests/units/anta_tests/routing/test_bgp.py index 799f05828..729d079c8 100644 --- a/tests/units/anta_tests/routing/test_bgp.py +++ b/tests/units/anta_tests/routing/test_bgp.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.routing.bgp.py -""" +"""Tests for anta.tests.routing.bgp.py.""" + # pylint: disable=C0302 from __future__ import annotations @@ -11,7 +10,7 @@ # pylint: disable=C0413 # because of the patch above -from anta.tests.routing.bgp import ( # noqa: E402 +from anta.tests.routing.bgp import ( VerifyBGPAdvCommunities, VerifyBGPExchangedRoutes, VerifyBGPPeerASNCap, @@ -67,9 +66,9 @@ "peerState": "Established", }, }, - } - } - } + }, + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 2}]}, "expected": {"result": "success"}, @@ -114,9 +113,9 @@ "peerState": "Established", }, }, - } - } - } + }, + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "num_peers": 3}]}, "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}]"]}, @@ -197,8 +196,8 @@ }, }, }, - } - } + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 3}]}, "expected": {"result": "success"}, @@ -265,8 +264,8 @@ }, }, }, - } - } + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all", "num_peers": 5}]}, "expected": {"result": "failure", "messages": ["Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'all': 'Expected: 5, Actual: 3'}}]"]}, @@ -311,8 +310,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, { "vrfs": { @@ -350,15 +349,15 @@ "peerState": "Established", }, }, - } - } + }, + }, }, ], "inputs": { "address_families": [ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 2}, {"afi": "evpn", "num_peers": 2}, - ] + ], }, "expected": { "result": "success", @@ -404,8 +403,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, {"vrfs": {}}, { @@ -444,8 +443,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, ], "inputs": { @@ -453,14 +452,14 @@ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "num_peers": 3}, {"afi": "evpn", "num_peers": 3}, {"afi": "ipv6", "safi": "unicast", "vrf": "default", "num_peers": 3}, - ] + ], }, "expected": { "result": "failure", "messages": [ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'PROD': 'Expected: 3, Actual: 2'}}, " "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}" + "{'afi': 'evpn', 'vrfs': {'default': 'Expected: 3, Actual: 2'}}", ], }, }, @@ -504,9 +503,9 @@ "peerState": "Established", }, }, - } - } - } + }, + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default"}]}, "expected": {"result": "success"}, @@ -551,15 +550,15 @@ "peerState": "Established", }, }, - } - } - } + }, + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default"}]}, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]" + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]", ], }, }, @@ -639,8 +638,8 @@ }, }, }, - } - } + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all"}]}, "expected": { @@ -723,15 +722,15 @@ }, }, }, - } - } + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "all"}]}, "expected": { "result": "failure", "messages": [ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}, " - "'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 100, 'outMsgQueue': 200}}}}]" + "'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 100, 'outMsgQueue': 200}}}}]", ], }, }, @@ -789,8 +788,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, { "vrfs": { @@ -828,15 +827,15 @@ "peerState": "Established", }, }, - } - } + }, + }, }, ], "inputs": { "address_families": [ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD"}, {"afi": "evpn"}, - ] + ], }, "expected": { "result": "success", @@ -882,8 +881,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, {"vrfs": {}}, { @@ -922,8 +921,8 @@ "peerState": "Idle", }, }, - } - } + }, + }, }, ], "inputs": { @@ -931,7 +930,7 @@ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD"}, {"afi": "evpn"}, {"afi": "ipv6", "safi": "unicast", "vrf": "default"}, - ] + ], }, "expected": { "result": "failure", @@ -939,7 +938,7 @@ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': " "{'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 10, 'outMsgQueue': 0}}}}, " "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}" + "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}", ], }, }, @@ -983,9 +982,9 @@ "peerState": "Established", }, }, - } - } - } + }, + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "peers": ["10.1.255.0", "10.1.255.2"]}]}, "expected": {"result": "success"}, @@ -1030,15 +1029,15 @@ "peerState": "Established", }, }, - } - } - } + }, + }, + }, ], "inputs": {"address_families": [{"afi": "ipv4", "safi": "unicast", "vrf": "default", "peers": ["10.1.255.0", "10.1.255.2"]}]}, "expected": { "result": "failure", "messages": [ - "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]" + "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': {'default': {'10.1.255.0': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}}]", ], }, }, @@ -1099,8 +1098,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, { "vrfs": { @@ -1138,15 +1137,15 @@ "peerState": "Established", }, }, - } - } + }, + }, }, ], "inputs": { "address_families": [ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "peers": ["10.1.254.1", "192.168.1.11"]}, {"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]}, - ] + ], }, "expected": {"result": "success"}, }, @@ -1190,8 +1189,8 @@ "peerState": "Established", }, }, - } - } + }, + }, }, {"vrfs": {}}, { @@ -1230,8 +1229,8 @@ "peerState": "Idle", }, }, - } - } + }, + }, }, ], "inputs": { @@ -1239,7 +1238,7 @@ {"afi": "ipv4", "safi": "unicast", "vrf": "PROD", "peers": ["10.1.254.1", "192.168.1.11"]}, {"afi": "evpn", "peers": ["10.1.0.1", "10.1.0.2"]}, {"afi": "ipv6", "safi": "unicast", "vrf": "default", "peers": ["10.1.0.1", "10.1.0.2"]}, - ] + ], }, "expected": { "result": "failure", @@ -1247,7 +1246,7 @@ "Failures: [{'afi': 'ipv4', 'safi': 'unicast', 'vrfs': " "{'PROD': {'192.168.1.11': {'peerState': 'Established', 'inMsgQueue': 10, 'outMsgQueue': 0}}}}, " "{'afi': 'ipv6', 'safi': 'unicast', 'vrfs': {'default': 'Not Configured'}}, " - "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}" + "{'afi': 'evpn', 'vrfs': {'default': {'10.1.0.2': {'peerState': 'Idle', 'inMsgQueue': 0, 'outMsgQueue': 0}}}", ], }, }, diff --git a/tests/units/anta_tests/routing/test_generic.py b/tests/units/anta_tests/routing/test_generic.py index 90e70f851..36658f5b2 100644 --- a/tests/units/anta_tests/routing/test_generic.py +++ b/tests/units/anta_tests/routing/test_generic.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.routing.generic.py -""" +"""Tests for anta.tests.routing.generic.py.""" + from __future__ import annotations from typing import Any @@ -43,9 +42,9 @@ # Output truncated "maskLen": {"8": 2}, "totalRoutes": 123, - } + }, }, - } + }, ], "inputs": {"minimum": 42, "maximum": 666}, "expected": {"result": "success"}, @@ -60,9 +59,9 @@ # Output truncated "maskLen": {"8": 2}, "totalRoutes": 1000, - } + }, }, - } + }, ], "inputs": {"minimum": 42, "maximum": 666}, "expected": {"result": "failure", "messages": ["routing-table has 1000 routes and not between min (42) and maximum (666)"]}, @@ -99,10 +98,10 @@ "preference": 20, "metric": 0, "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], - } + }, }, - } - } + }, + }, }, { "vrfs": { @@ -122,10 +121,10 @@ "preference": 20, "metric": 0, "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], - } + }, }, - } - } + }, + }, }, ], "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, @@ -143,8 +142,8 @@ "allRoutesProgrammedKernel": True, "defaultRouteState": "notSet", "routes": {}, - } - } + }, + }, }, { "vrfs": { @@ -164,10 +163,10 @@ "preference": 20, "metric": 0, "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], - } + }, }, - } - } + }, + }, }, ], "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, @@ -195,10 +194,10 @@ "preference": 20, "metric": 0, "vias": [{"nexthopAddr": "10.1.255.4", "interface": "Ethernet1"}], - } + }, }, - } - } + }, + }, }, { "vrfs": { @@ -218,10 +217,10 @@ "preference": 20, "metric": 0, "vias": [{"nexthopAddr": "10.1.255.6", "interface": "Ethernet2"}], - } + }, }, - } - } + }, + }, }, ], "inputs": {"vrf": "default", "routes": ["10.1.0.1", "10.1.0.2"]}, diff --git a/tests/units/anta_tests/routing/test_ospf.py b/tests/units/anta_tests/routing/test_ospf.py index fbabee91e..3bd213f84 100644 --- a/tests/units/anta_tests/routing/test_ospf.py +++ b/tests/units/anta_tests/routing/test_ospf.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.routing.ospf.py -""" +"""Tests for anta.tests.routing.ospf.py.""" + from __future__ import annotations from typing import Any @@ -40,9 +39,9 @@ "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", }, - ] - } - } + ], + }, + }, }, "BLAH": { "instList": { @@ -56,13 +55,13 @@ "adjacencyState": "full", "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", - } - ] - } - } + }, + ], + }, + }, }, - } - } + }, + }, ], "inputs": None, "expected": {"result": "success"}, @@ -95,9 +94,9 @@ "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", }, - ] - } - } + ], + }, + }, }, "BLAH": { "instList": { @@ -111,20 +110,20 @@ "adjacencyState": "down", "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", - } - ] - } - } + }, + ], + }, + }, }, - } - } + }, + }, ], "inputs": None, "expected": { "result": "failure", "messages": [ "Some neighbors are not correctly configured: [{'vrf': 'default', 'instance': '666', 'neighbor': '7.7.7.7', 'state': '2-way'}," - " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}]." + " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}].", ], }, }, @@ -134,7 +133,7 @@ "eos_data": [ { "vrfs": {}, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]}, @@ -167,9 +166,9 @@ "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", }, - ] - } - } + ], + }, + }, }, "BLAH": { "instList": { @@ -183,13 +182,13 @@ "adjacencyState": "full", "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", - } - ] - } - } + }, + ], + }, + }, }, - } - } + }, + }, ], "inputs": {"number": 3}, "expected": {"result": "success"}, @@ -213,12 +212,12 @@ "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", }, - ] - } - } - } - } - } + ], + }, + }, + }, + }, + }, ], "inputs": {"number": 3}, "expected": {"result": "failure", "messages": ["device has 1 neighbors (expected 3)"]}, @@ -251,9 +250,9 @@ "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", }, - ] - } - } + ], + }, + }, }, "BLAH": { "instList": { @@ -267,20 +266,20 @@ "adjacencyState": "down", "inactivity": 1683298014.844345, "interfaceAddress": "10.3.0.1", - } - ] - } - } + }, + ], + }, + }, }, - } - } + }, + }, ], "inputs": {"number": 3}, "expected": { "result": "failure", "messages": [ "Some neighbors are not correctly configured: [{'vrf': 'default', 'instance': '666', 'neighbor': '7.7.7.7', 'state': '2-way'}," - " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}]." + " {'vrf': 'BLAH', 'instance': '777', 'neighbor': '8.8.8.8', 'state': 'down'}].", ], }, }, @@ -290,7 +289,7 @@ "eos_data": [ { "vrfs": {}, - } + }, ], "inputs": {"number": 3}, "expected": {"result": "skipped", "messages": ["no OSPF neighbor found"]}, diff --git a/tests/units/anta_tests/test_aaa.py b/tests/units/anta_tests/test_aaa.py index 29922906f..f0324c503 100644 --- a/tests/units/anta_tests/test_aaa.py +++ b/tests/units/anta_tests/test_aaa.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.aaa.py -""" +"""Tests for anta.tests.aaa.py.""" + from __future__ import annotations from typing import Any @@ -28,11 +27,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management0"}, - } + }, ], "inputs": {"intf": "Management0", "vrf": "MGMT"}, "expected": {"result": "success"}, @@ -45,7 +44,7 @@ "tacacsServers": [], "groups": {}, "srcIntf": {}, - } + }, ], "inputs": {"intf": "Management0", "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]}, @@ -58,11 +57,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management1"}, - } + }, ], "inputs": {"intf": "Management0", "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Wrong source-interface configured in VRF MGMT"]}, @@ -75,11 +74,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"PROD": "Management0"}, - } + }, ], "inputs": {"intf": "Management0", "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Source-interface Management0 is not configured in VRF MGMT"]}, @@ -92,11 +91,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management0"}, - } + }, ], "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, "expected": {"result": "success"}, @@ -109,7 +108,7 @@ "tacacsServers": [], "groups": {}, "srcIntf": {}, - } + }, ], "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["No TACACS servers are configured"]}, @@ -122,11 +121,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management0"}, - } + }, ], "inputs": {"servers": ["10.22.10.91", "10.22.10.92"], "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.92'] are not configured in VRF MGMT"]}, @@ -139,11 +138,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "PROD"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management0"}, - } + }, ], "inputs": {"servers": ["10.22.10.91"], "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["TACACS servers ['10.22.10.91'] are not configured in VRF MGMT"]}, @@ -156,11 +155,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP1": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management0"}, - } + }, ], "inputs": {"groups": ["GROUP1"]}, "expected": {"result": "success"}, @@ -173,7 +172,7 @@ "tacacsServers": [], "groups": {}, "srcIntf": {}, - } + }, ], "inputs": {"groups": ["GROUP1"]}, "expected": {"result": "failure", "messages": ["No TACACS server group(s) are configured"]}, @@ -186,11 +185,11 @@ "tacacsServers": [ { "serverInfo": {"hostname": "10.22.10.91", "authport": 49, "vrf": "MGMT"}, - } + }, ], "groups": {"GROUP2": {"serverGroup": "TACACS+", "members": [{"hostname": "SERVER1", "authport": 49, "vrf": "MGMT"}]}}, "srcIntf": {"MGMT": "Management0"}, - } + }, ], "inputs": {"groups": ["GROUP1"]}, "expected": {"result": "failure", "messages": ["TACACS server group(s) ['GROUP1'] are not configured"]}, @@ -203,7 +202,7 @@ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group tacacs+", "local"]}}, "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, "expected": {"result": "success"}, @@ -216,7 +215,7 @@ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group tacacs+", "local"]}}, "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, - } + }, ], "inputs": {"methods": ["radius"], "types": ["dot1x"]}, "expected": {"result": "success"}, @@ -229,7 +228,7 @@ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, "expected": {"result": "failure", "messages": ["AAA authentication methods are not configured for login console"]}, @@ -242,7 +241,7 @@ "loginAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}, "login": {"methods": ["group radius", "local"]}}, "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for login console"]}, @@ -255,7 +254,7 @@ "loginAuthenMethods": {"default": {"methods": ["group radius", "local"]}, "login": {"methods": ["group tacacs+", "local"]}}, "enableAuthenMethods": {"default": {"methods": ["group tacacs+", "local"]}}, "dot1xAuthenMethods": {"default": {"methods": ["group radius"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["login", "enable"]}, "expected": {"result": "failure", "messages": ["AAA authentication methods ['group tacacs+', 'local'] are not matching for ['login']"]}, @@ -267,7 +266,7 @@ { "commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}}, "execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, "expected": {"result": "success"}, @@ -279,7 +278,7 @@ { "commandsAuthzMethods": {"privilege0-15": {"methods": ["group radius", "local"]}}, "execAuthzMethods": {"exec": {"methods": ["group tacacs+", "local"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['commands']"]}, @@ -291,7 +290,7 @@ { "commandsAuthzMethods": {"privilege0-15": {"methods": ["group tacacs+", "local"]}}, "execAuthzMethods": {"exec": {"methods": ["group radius", "local"]}}, - } + }, ], "inputs": {"methods": ["tacacs+", "local"], "types": ["commands", "exec"]}, "expected": {"result": "failure", "messages": ["AAA authorization methods ['group tacacs+', 'local'] are not matching for ['exec']"]}, @@ -305,7 +304,7 @@ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "success"}, @@ -319,7 +318,7 @@ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "dot1xAcctMethods": {"dot1x": {"defaultAction": "startStop", "defaultMethods": ["group radius", "logging"], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["radius", "logging"], "types": ["dot1x"]}, "expected": {"result": "success"}, @@ -333,7 +332,7 @@ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['commands']"]}, @@ -347,7 +346,7 @@ "execAcctMethods": {"exec": {"defaultMethods": [], "consoleMethods": []}}, "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}}, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "failure", "messages": ["AAA default accounting is not configured for ['system', 'exec', 'commands']"]}, @@ -361,7 +360,7 @@ "execAcctMethods": {"exec": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "systemAcctMethods": {"system": {"defaultAction": "startStop", "defaultMethods": ["group tacacs+", "logging"], "consoleMethods": []}}, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "failure", "messages": ["AAA accounting default methods ['group tacacs+', 'logging'] are not matching for ['commands']"]}, @@ -376,24 +375,24 @@ "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "execAcctMethods": { "exec": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "systemAcctMethods": { "system": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "success"}, @@ -408,30 +407,30 @@ "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "execAcctMethods": { "exec": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "systemAcctMethods": { "system": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "dot1xAcctMethods": { "dot1x": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["dot1x"]}, "expected": {"result": "success"}, @@ -445,24 +444,24 @@ "privilege0-15": { "defaultMethods": [], "consoleMethods": [], - } + }, }, "execAcctMethods": { "exec": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "systemAcctMethods": { "system": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['commands']"]}, @@ -476,7 +475,7 @@ "execAcctMethods": {"exec": {"defaultMethods": [], "consoleMethods": []}}, "commandsAcctMethods": {"privilege0-15": {"defaultMethods": [], "consoleMethods": []}}, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "failure", "messages": ["AAA console accounting is not configured for ['system', 'exec', 'commands']"]}, @@ -491,24 +490,24 @@ "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group radius", "logging"], - } + }, }, "execAcctMethods": { "exec": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "systemAcctMethods": { "system": { "defaultMethods": [], "consoleAction": "startStop", "consoleMethods": ["group tacacs+", "logging"], - } + }, }, "dot1xAcctMethods": {"dot1x": {"defaultMethods": [], "consoleMethods": []}}, - } + }, ], "inputs": {"methods": ["tacacs+", "logging"], "types": ["commands", "exec", "system"]}, "expected": {"result": "failure", "messages": ["AAA accounting console methods ['group tacacs+', 'logging'] are not matching for ['commands']"]}, diff --git a/tests/units/anta_tests/test_bfd.py b/tests/units/anta_tests/test_bfd.py index 67bb0b47a..54dc7a05e 100644 --- a/tests/units/anta_tests/test_bfd.py +++ b/tests/units/anta_tests/test_bfd.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.bfd.py -""" +"""Tests for anta.tests.bfd.py.""" + # pylint: disable=C0302 from __future__ import annotations @@ -11,7 +10,7 @@ # pylint: disable=C0413 # because of the patch above -from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDSpecificPeers # noqa: E402 +from anta.tests.bfd import VerifyBFDPeersHealth, VerifyBFDPeersIntervals, VerifyBFDSpecificPeers from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 DATA: list[dict[str, Any]] = [ diff --git a/tests/units/anta_tests/test_configuration.py b/tests/units/anta_tests/test_configuration.py index a2ab6731d..0444db6f5 100644 --- a/tests/units/anta_tests/test_configuration.py +++ b/tests/units/anta_tests/test_configuration.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Data for testing anta.tests.configuration""" +"""Data for testing anta.tests.configuration.""" + from __future__ import annotations from typing import Any diff --git a/tests/units/anta_tests/test_connectivity.py b/tests/units/anta_tests/test_connectivity.py index f79ce242d..f36aca2ad 100644 --- a/tests/units/anta_tests/test_connectivity.py +++ b/tests/units/anta_tests/test_connectivity.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.connectivity.py -""" +"""Tests for anta.tests.connectivity.py.""" + from __future__ import annotations from typing import Any @@ -27,8 +26,8 @@ 2 packets transmitted, 2 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, { "messages": [ @@ -40,8 +39,8 @@ 2 packets transmitted, 2 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, ], "expected": {"result": "success"}, @@ -61,8 +60,8 @@ 2 packets transmitted, 2 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, { "messages": [ @@ -74,8 +73,8 @@ 2 packets transmitted, 2 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, ], "expected": {"result": "success"}, @@ -94,8 +93,8 @@ 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, ], "expected": {"result": "success"}, @@ -115,8 +114,8 @@ 2 packets transmitted, 0 received, 100% packet loss, time 10ms - """ - ] + """, + ], }, { "messages": [ @@ -128,8 +127,8 @@ 2 packets transmitted, 2 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, ], "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('10.0.0.5', '10.0.0.11')]"]}, @@ -149,8 +148,8 @@ 2 packets transmitted, 0 received, 100% packet loss, time 10ms - """ - ] + """, + ], }, { "messages": [ @@ -162,8 +161,8 @@ 2 packets transmitted, 2 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.072/0.159/0.247/0.088 ms, ipg/ewma 0.370/0.225 ms - """ - ] + """, + ], }, ], "expected": {"result": "failure", "messages": ["Connectivity test failed for the following source-destination pairs: [('Management0', '10.0.0.11')]"]}, @@ -175,7 +174,7 @@ "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ] + ], }, "eos_data": [ { @@ -192,8 +191,8 @@ "interfaceId_v2": "Ethernet1", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", }, - } - ] + }, + ], }, "Ethernet2": { "lldpNeighborInfo": [ @@ -207,11 +206,11 @@ "interfaceId_v2": "Ethernet1", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet2", }, - } - ] + }, + ], }, - } - } + }, + }, ], "expected": {"result": "success"}, }, @@ -222,7 +221,7 @@ "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ] + ], }, "eos_data": [ { @@ -239,11 +238,11 @@ "interfaceId_v2": "Ethernet1", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", }, - } - ] + }, + ], }, - } - } + }, + }, ], "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'port_not_configured': ['Ethernet2']}"]}, }, @@ -254,7 +253,7 @@ "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ] + ], }, "eos_data": [ { @@ -271,12 +270,12 @@ "interfaceId_v2": "Ethernet1", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", }, - } - ] + }, + ], }, "Ethernet2": {"lldpNeighborInfo": []}, - } - } + }, + }, ], "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'no_lldp_neighbor': ['Ethernet2']}"]}, }, @@ -287,7 +286,7 @@ "neighbors": [ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, - ] + ], }, "eos_data": [ { @@ -304,8 +303,8 @@ "interfaceId_v2": "Ethernet1", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", }, - } - ] + }, + ], }, "Ethernet2": { "lldpNeighborInfo": [ @@ -319,11 +318,11 @@ "interfaceId_v2": "Ethernet2", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet2", }, - } - ] + }, + ], }, - } - } + }, + }, ], "expected": {"result": "failure", "messages": ["The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet2']}"]}, }, @@ -335,7 +334,7 @@ {"port": "Ethernet1", "neighbor_device": "DC1-SPINE1", "neighbor_port": "Ethernet1"}, {"port": "Ethernet2", "neighbor_device": "DC1-SPINE2", "neighbor_port": "Ethernet1"}, {"port": "Ethernet3", "neighbor_device": "DC1-SPINE3", "neighbor_port": "Ethernet1"}, - ] + ], }, "eos_data": [ { @@ -352,17 +351,17 @@ "interfaceId_v2": "Ethernet2", "interfaceDescription": "P2P_LINK_TO_DC1-LEAF1A_Ethernet1", }, - } - ] + }, + ], }, "Ethernet2": {"lldpNeighborInfo": []}, - } - } + }, + }, ], "expected": { "result": "failure", "messages": [ - "The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet1'], 'no_lldp_neighbor': ['Ethernet2'], 'port_not_configured': ['Ethernet3']}" + "The following port(s) have issues: {'wrong_lldp_neighbor': ['Ethernet1'], 'no_lldp_neighbor': ['Ethernet2'], 'port_not_configured': ['Ethernet3']}", ], }, }, diff --git a/tests/units/anta_tests/test_field_notices.py b/tests/units/anta_tests/test_field_notices.py index 7c17f22b1..12c62be84 100644 --- a/tests/units/anta_tests/test_field_notices.py +++ b/tests/units/anta_tests/test_field_notices.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Test inputs for anta.tests.field_notices""" +"""Test inputs for anta.tests.field_notices.""" + from __future__ import annotations from typing import Any @@ -22,7 +23,7 @@ "deviations": [], "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}], }, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -39,7 +40,7 @@ "deviations": [], "components": [{"name": "Aboot", "version": "Aboot-veos-4.0.1-3255441"}], }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (4.0.1)"]}, @@ -56,7 +57,7 @@ "deviations": [], "components": [{"name": "Aboot", "version": "Aboot-veos-4.1.0-3255441"}], }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (4.1.0)"]}, @@ -73,7 +74,7 @@ "deviations": [], "components": [{"name": "Aboot", "version": "Aboot-veos-6.0.1-3255441"}], }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (6.0.1)"]}, @@ -90,7 +91,7 @@ "deviations": [], "components": [{"name": "Aboot", "version": "Aboot-veos-6.1.1-3255441"}], }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["device is running incorrect version of aboot (6.1.1)"]}, @@ -107,7 +108,7 @@ "deviations": [], "components": [{"name": "Aboot", "version": "Aboot-veos-8.0.0-3255441"}], }, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["device is not impacted by FN044"]}, @@ -123,7 +124,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "7"}], }, - } + }, ], "inputs": None, "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, @@ -139,7 +140,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "7"}], }, - } + }, ], "inputs": None, "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, @@ -155,7 +156,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "7"}], }, - } + }, ], "inputs": None, "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, @@ -171,7 +172,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "7"}], }, - } + }, ], "inputs": None, "expected": {"result": "success", "messages": ["FN72 is mitigated"]}, @@ -187,7 +188,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "7"}], }, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["Device not exposed"]}, @@ -203,7 +204,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "5"}], }, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["Platform is not impacted by FN072"]}, @@ -219,7 +220,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "5"}], }, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["Device not exposed"]}, @@ -235,7 +236,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "5"}], }, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["Device not exposed"]}, @@ -251,7 +252,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "5"}], }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Device is exposed to FN72"]}, @@ -267,7 +268,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm1", "version": "5"}], }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Device is exposed to FN72"]}, @@ -283,7 +284,7 @@ "deviations": [], "components": [{"name": "FixedSystemvrm2", "version": "5"}], }, - } + }, ], "inputs": None, "expected": {"result": "error", "messages": ["Error in running test - FixedSystemvrm1 not found"]}, diff --git a/tests/units/anta_tests/test_greent.py b/tests/units/anta_tests/test_greent.py index 65789a2b9..2c483012d 100644 --- a/tests/units/anta_tests/test_greent.py +++ b/tests/units/anta_tests/test_greent.py @@ -1,12 +1,14 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Data for testing anta.tests.configuration""" +"""Data for testing anta.tests.configuration.""" + from __future__ import annotations from typing import Any from anta.tests.greent import VerifyGreenT, VerifyGreenTCounters +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 DATA: list[dict[str, Any]] = [ { @@ -21,12 +23,19 @@ "test": VerifyGreenTCounters, "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 0, "sampleSent": 0}], "inputs": None, - "expected": {"result": "failure"}, + "expected": {"result": "failure", "messages": ["GreenT counters are not incremented"]}, }, { "name": "success", "test": VerifyGreenT, - "eos_data": [{"sampleRcvd": 0, "sampleDiscarded": 0, "multiDstSampleRcvd": 0, "grePktSent": 1, "sampleSent": 0}], + "eos_data": [ + { + "profiles": { + "default": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}}, + "testProfile": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}}, + }, + }, + ], "inputs": None, "expected": {"result": "success"}, }, @@ -37,11 +46,10 @@ { "profiles": { "default": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}}, - "testProfile": {"interfaces": [], "appliedInterfaces": [], "samplePolicy": "default", "failures": {}, "appliedInterfaces6": [], "failures6": {}}, - } - } + }, + }, ], "inputs": None, - "expected": {"result": "failure"}, + "expected": {"result": "failure", "messages": ["No GreenT policy is created"]}, }, ] diff --git a/tests/units/anta_tests/test_hardware.py b/tests/units/anta_tests/test_hardware.py index 5279d8915..e601c681a 100644 --- a/tests/units/anta_tests/test_hardware.py +++ b/tests/units/anta_tests/test_hardware.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Test inputs for anta.tests.hardware""" +"""Test inputs for anta.tests.hardware.""" + from __future__ import annotations from typing import Any @@ -26,8 +27,8 @@ "xcvrSlots": { "1": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501340", "hardwareRev": "21"}, "2": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501337", "hardwareRev": "21"}, - } - } + }, + }, ], "inputs": {"manufacturers": ["Arista Networks"]}, "expected": {"result": "success"}, @@ -40,8 +41,8 @@ "xcvrSlots": { "1": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501340", "hardwareRev": "21"}, "2": {"mfgName": "Arista Networks", "modelName": "QSFP-100G-DR", "serialNum": "XKT203501337", "hardwareRev": "21"}, - } - } + }, + }, ], "inputs": {"manufacturers": ["Arista"]}, "expected": {"result": "failure", "messages": ["Some transceivers are from unapproved manufacturers: {'1': 'Arista Networks', '2': 'Arista Networks'}"]}, @@ -57,7 +58,7 @@ "shutdownOnOverheat": "True", "systemStatus": "temperatureOk", "recoveryModeOnOverheat": "recoveryModeNA", - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -73,7 +74,7 @@ "shutdownOnOverheat": "True", "systemStatus": "temperatureKO", "recoveryModeOnOverheat": "recoveryModeNA", - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Device temperature exceeds acceptable limits. Current system status: 'temperatureKO'"]}, @@ -100,10 +101,10 @@ "pidDriverCount": 0, "isPidDriver": False, "name": "DomTemperatureSensor54", - } + }, ], "cardSlots": [], - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -130,10 +131,10 @@ "pidDriverCount": 0, "isPidDriver": False, "name": "DomTemperatureSensor54", - } + }, ], "cardSlots": [], - } + }, ], "inputs": None, "expected": { @@ -141,7 +142,7 @@ "messages": [ "The following sensors are operating outside the acceptable temperature range or have raised alerts: " "{'DomTemperatureSensor54': " - "{'hwStatus': 'ko', 'alertCount': 0}}" + "{'hwStatus': 'ko', 'alertCount': 0}}", ], }, }, @@ -167,10 +168,10 @@ "pidDriverCount": 0, "isPidDriver": False, "name": "DomTemperatureSensor54", - } + }, ], "cardSlots": [], - } + }, ], "inputs": None, "expected": { @@ -178,7 +179,7 @@ "messages": [ "The following sensors are operating outside the acceptable temperature range or have raised alerts: " "{'DomTemperatureSensor54': " - "{'hwStatus': 'ok', 'alertCount': 1}}" + "{'hwStatus': 'ok', 'alertCount': 1}}", ], }, }, @@ -200,7 +201,7 @@ "currentZones": 1, "configuredZones": 0, "systemStatus": "coolingOk", - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -223,7 +224,7 @@ "currentZones": 1, "configuredZones": 0, "systemStatus": "coolingKo", - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Device system cooling is not OK: 'coolingKo'"]}, @@ -254,7 +255,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply1/1", - } + }, ], "speed": 30, "label": "PowerSupply1", @@ -272,7 +273,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply2/1", - } + }, ], "speed": 30, "label": "PowerSupply2", @@ -292,7 +293,7 @@ "speedHwOverride": False, "speedStable": True, "label": "1/1", - } + }, ], "speed": 30, "label": "1", @@ -310,7 +311,7 @@ "speedHwOverride": False, "speedStable": True, "label": "2/1", - } + }, ], "speed": 30, "label": "2", @@ -328,7 +329,7 @@ "speedHwOverride": False, "speedStable": True, "label": "3/1", - } + }, ], "speed": 30, "label": "3", @@ -346,7 +347,7 @@ "speedHwOverride": False, "speedStable": True, "label": "4/1", - } + }, ], "speed": 30, "label": "4", @@ -356,7 +357,7 @@ "currentZones": 1, "configuredZones": 0, "systemStatus": "coolingOk", - } + }, ], "inputs": {"states": ["ok"]}, "expected": {"result": "success"}, @@ -387,7 +388,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply1/1", - } + }, ], "speed": 30, "label": "PowerSupply1", @@ -405,7 +406,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply2/1", - } + }, ], "speed": 30, "label": "PowerSupply2", @@ -425,7 +426,7 @@ "speedHwOverride": False, "speedStable": True, "label": "1/1", - } + }, ], "speed": 30, "label": "1", @@ -443,7 +444,7 @@ "speedHwOverride": False, "speedStable": True, "label": "2/1", - } + }, ], "speed": 30, "label": "2", @@ -461,7 +462,7 @@ "speedHwOverride": False, "speedStable": True, "label": "3/1", - } + }, ], "speed": 30, "label": "3", @@ -479,7 +480,7 @@ "speedHwOverride": False, "speedStable": True, "label": "4/1", - } + }, ], "speed": 30, "label": "4", @@ -489,7 +490,7 @@ "currentZones": 1, "configuredZones": 0, "systemStatus": "coolingOk", - } + }, ], "inputs": {"states": ["ok", "Not Inserted"]}, "expected": {"result": "success"}, @@ -520,7 +521,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply1/1", - } + }, ], "speed": 30, "label": "PowerSupply1", @@ -538,7 +539,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply2/1", - } + }, ], "speed": 30, "label": "PowerSupply2", @@ -558,7 +559,7 @@ "speedHwOverride": False, "speedStable": True, "label": "1/1", - } + }, ], "speed": 30, "label": "1", @@ -576,7 +577,7 @@ "speedHwOverride": False, "speedStable": True, "label": "2/1", - } + }, ], "speed": 30, "label": "2", @@ -594,7 +595,7 @@ "speedHwOverride": False, "speedStable": True, "label": "3/1", - } + }, ], "speed": 30, "label": "3", @@ -612,7 +613,7 @@ "speedHwOverride": False, "speedStable": True, "label": "4/1", - } + }, ], "speed": 30, "label": "4", @@ -622,7 +623,7 @@ "currentZones": 1, "configuredZones": 0, "systemStatus": "CoolingKo", - } + }, ], "inputs": {"states": ["ok", "Not Inserted"]}, "expected": {"result": "failure", "messages": ["Fan 1/1 on Fan Tray 1 is: 'down'"]}, @@ -653,7 +654,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply1/1", - } + }, ], "speed": 30, "label": "PowerSupply1", @@ -671,7 +672,7 @@ "speedHwOverride": True, "speedStable": True, "label": "PowerSupply2/1", - } + }, ], "speed": 30, "label": "PowerSupply2", @@ -691,7 +692,7 @@ "speedHwOverride": False, "speedStable": True, "label": "1/1", - } + }, ], "speed": 30, "label": "1", @@ -709,7 +710,7 @@ "speedHwOverride": False, "speedStable": True, "label": "2/1", - } + }, ], "speed": 30, "label": "2", @@ -727,7 +728,7 @@ "speedHwOverride": False, "speedStable": True, "label": "3/1", - } + }, ], "speed": 30, "label": "3", @@ -745,7 +746,7 @@ "speedHwOverride": False, "speedStable": True, "label": "4/1", - } + }, ], "speed": 30, "label": "4", @@ -755,7 +756,7 @@ "currentZones": 1, "configuredZones": 0, "systemStatus": "CoolingKo", - } + }, ], "inputs": {"states": ["ok", "Not Inserted"]}, "expected": {"result": "failure", "messages": ["Fan PowerSupply1/1 on PowerSupply PowerSupply1 is: 'down'"]}, @@ -801,8 +802,8 @@ "outputCurrent": 9.828125, "managed": True, }, - } - } + }, + }, ], "inputs": {"states": ["ok"]}, "expected": {"result": "success"}, @@ -848,8 +849,8 @@ "outputCurrent": 9.828125, "managed": True, }, - } - } + }, + }, ], "inputs": {"states": ["ok", "Not Inserted"]}, "expected": {"result": "success"}, @@ -895,8 +896,8 @@ "outputCurrent": 9.828125, "managed": True, }, - } - } + }, + }, ], "inputs": {"states": ["ok"]}, "expected": {"result": "failure", "messages": ["The following power supplies status are not in the accepted states list: {'1': {'state': 'powerLoss'}}"]}, diff --git a/tests/units/anta_tests/test_interfaces.py b/tests/units/anta_tests/test_interfaces.py index 142216cab..9a08700da 100644 --- a/tests/units/anta_tests/test_interfaces.py +++ b/tests/units/anta_tests/test_interfaces.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Test inputs for anta.tests.hardware""" +"""Test inputs for anta.tests.hardware.""" + from __future__ import annotations from typing import Any @@ -321,8 +322,8 @@ "interfaceErrorCounters": { "Ethernet1": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, - } - } + }, + }, ], "inputs": None, "expected": {"result": "success"}, @@ -335,8 +336,8 @@ "interfaceErrorCounters": { "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 666, "symbolErrors": 0}, - } - } + }, + }, ], "inputs": None, "expected": { @@ -344,7 +345,7 @@ "messages": [ "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts': 0," " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}, {'Ethernet6': {'inErrors': 0, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts':" - " 0, 'fcsErrors': 0, 'alignmentErrors': 666, 'symbolErrors': 0}}]" + " 0, 'fcsErrors': 0, 'alignmentErrors': 666, 'symbolErrors': 0}}]", ], }, }, @@ -356,8 +357,8 @@ "interfaceErrorCounters": { "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 10, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, "Ethernet6": {"inErrors": 0, "frameTooLongs": 0, "outErrors": 0, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 6, "symbolErrors": 10}, - } - } + }, + }, ], "inputs": None, "expected": { @@ -365,7 +366,7 @@ "messages": [ "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 10, 'frameTooShorts': 0," " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}, {'Ethernet6': {'inErrors': 0, 'frameTooLongs': 0, 'outErrors': 0, 'frameTooShorts':" - " 0, 'fcsErrors': 0, 'alignmentErrors': 6, 'symbolErrors': 10}}]" + " 0, 'fcsErrors': 0, 'alignmentErrors': 6, 'symbolErrors': 10}}]", ], }, }, @@ -376,15 +377,15 @@ { "interfaceErrorCounters": { "Ethernet1": {"inErrors": 42, "frameTooLongs": 0, "outErrors": 2, "frameTooShorts": 0, "fcsErrors": 0, "alignmentErrors": 0, "symbolErrors": 0}, - } - } + }, + }, ], "inputs": None, "expected": { "result": "failure", "messages": [ "The following interface(s) have non-zero error counters: [{'Ethernet1': {'inErrors': 42, 'frameTooLongs': 0, 'outErrors': 2, 'frameTooShorts': 0," - " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}]" + " 'fcsErrors': 0, 'alignmentErrors': 0, 'symbolErrors': 0}}]", ], }, }, @@ -399,7 +400,7 @@ "Ethernet1": {"outDiscards": 0, "inDiscards": 0}, }, "outDiscardsTotal": 0, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -415,14 +416,14 @@ "Ethernet1": {"outDiscards": 0, "inDiscards": 42}, }, "outDiscardsTotal": 0, - } + }, ], "inputs": None, "expected": { "result": "failure", "messages": [ "The following interfaces have non 0 discard counter(s): [{'Ethernet2': {'outDiscards': 42, 'inDiscards': 0}}," - " {'Ethernet1': {'outDiscards': 0, 'inDiscards': 42}}]" + " {'Ethernet1': {'outDiscards': 0, 'inDiscards': 42}}]", ], }, }, @@ -438,8 +439,8 @@ "Ethernet8": { "linkStatus": "connected", }, - } - } + }, + }, ], "inputs": None, "expected": {"result": "success"}, @@ -456,8 +457,8 @@ "Ethernet8": { "linkStatus": "errdisabled", }, - } - } + }, + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["The following interfaces are in error disabled state: ['Management1', 'Ethernet8']"]}, @@ -471,8 +472,8 @@ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "Ethernet2", "status": "adminDown"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, "expected": {"result": "success"}, @@ -520,8 +521,8 @@ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "ethernet2", "status": "adminDown"}, {"name": "ethernet8", "status": "up"}, {"name": "ethernet3", "status": "up"}]}, "expected": {"result": "success"}, @@ -535,8 +536,8 @@ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, "Ethernet2": {"interfaceStatus": "adminDown", "description": "", "lineProtocolStatus": "down"}, "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "eth2", "status": "adminDown"}, {"name": "et8", "status": "up"}, {"name": "et3", "status": "up"}]}, "expected": {"result": "success"}, @@ -548,8 +549,8 @@ { "interfaceDescriptions": { "Port-Channel100": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "po100", "status": "up"}]}, "expected": {"result": "success"}, @@ -561,8 +562,8 @@ { "interfaceDescriptions": { "Ethernet52/1.1963": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "Ethernet52/1.1963", "status": "up"}]}, "expected": {"result": "success"}, @@ -614,8 +615,8 @@ "interfaceDescriptions": { "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, "expected": { @@ -632,8 +633,8 @@ "Ethernet8": {"interfaceStatus": "down", "description": "", "lineProtocolStatus": "down"}, "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": {"interfaces": [{"name": "Ethernet2", "status": "up"}, {"name": "Ethernet8", "status": "up"}, {"name": "Ethernet3", "status": "up"}]}, "expected": { @@ -650,8 +651,8 @@ "Ethernet8": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "down"}, "Ethernet2": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, "Ethernet3": {"interfaceStatus": "up", "description": "", "lineProtocolStatus": "up"}, - } - } + }, + }, ], "inputs": { "interfaces": [ @@ -717,9 +718,9 @@ "active": True, "reason": "", "errdisabled": False, - } + }, }, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -736,9 +737,9 @@ "active": True, "reason": "", "errdisabled": False, - } + }, }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["The following interfaces have none 0 storm-control drop counters {'Ethernet1': {'broadcast': 666}}"]}, @@ -759,9 +760,9 @@ "inactivePorts": {}, "activePorts": {}, "inactiveLag": False, - } - } - } + }, + }, + }, ], "inputs": None, "expected": {"result": "success"}, @@ -782,9 +783,9 @@ "inactivePorts": {"Ethernet8": {"reasonUnconfigured": "waiting for LACP response"}}, "activePorts": {}, "inactiveLag": False, - } - } - } + }, + }, + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["The following port-channels have inactive port(s): ['Port-Channel42']"]}, @@ -806,12 +807,12 @@ "lacpdusTxCount": 454, "markersTxCount": 0, "markersRxCount": 0, - } - } - } + }, + }, + }, }, "orphanPorts": {}, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -833,17 +834,17 @@ "lacpdusTxCount": 454, "markersTxCount": 0, "markersRxCount": 0, - } - } - } + }, + }, + }, }, "orphanPorts": {}, - } + }, ], "inputs": None, "expected": { "result": "failure", - "messages": ["The following port-channels have recieved illegal lacp packets on the following ports: [{'Port-Channel42': 'Ethernet8'}]"], + "messages": ["The following port-channels have received illegal LACP packets on the following ports: [{'Port-Channel42': 'Ethernet8'}]"], }, }, { @@ -868,8 +869,8 @@ "lineProtocolStatus": "up", "mtu": 65535, }, - } - } + }, + }, ], "inputs": {"number": 2}, "expected": {"result": "success"}, @@ -896,8 +897,8 @@ "lineProtocolStatus": "down", "mtu": 65535, }, - } - } + }, + }, ], "inputs": {"number": 2}, "expected": {"result": "failure", "messages": ["The following Loopbacks are not up: ['Loopback666']"]}, @@ -916,8 +917,8 @@ "lineProtocolStatus": "up", "mtu": 65535, }, - } - } + }, + }, ], "inputs": {"number": 2}, "expected": {"result": "failure", "messages": ["Found 1 Loopbacks when expecting 2"]}, @@ -935,9 +936,9 @@ "ipv4Routable240": False, "lineProtocolStatus": "up", "mtu": 1500, - } - } - } + }, + }, + }, ], "inputs": None, "expected": {"result": "success"}, @@ -955,9 +956,9 @@ "ipv4Routable240": False, "lineProtocolStatus": "lowerLayerDown", "mtu": 1500, - } - } - } + }, + }, + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["The following SVIs are not up: ['Vlan42']"]}, @@ -1029,7 +1030,7 @@ "l2Mru": 0, }, }, - } + }, ], "inputs": {"mtu": 1500}, "expected": {"result": "success"}, @@ -1101,7 +1102,7 @@ "l2Mru": 0, }, }, - } + }, ], "inputs": {"mtu": 1500, "ignored_interfaces": ["Loopback", "Port-Channel", "Management", "Vxlan"], "specific_mtu": [{"Ethernet10": 1501}]}, "expected": {"result": "success"}, @@ -1173,7 +1174,7 @@ "l2Mru": 0, }, }, - } + }, ], "inputs": {"mtu": 1500}, "expected": {"result": "failure", "messages": ["Some interfaces do not have correct MTU configured:\n[{'Ethernet2': 1600}]"]}, @@ -1245,7 +1246,7 @@ "l2Mru": 0, }, }, - } + }, ], "inputs": {"mtu": 9214}, "expected": {"result": "success"}, @@ -1317,7 +1318,7 @@ "l2Mru": 0, }, }, - } + }, ], "inputs": {"mtu": 1500}, "expected": {"result": "failure", "messages": ["Some L2 interfaces do not have correct MTU configured:\n[{'Ethernet10': 9214}, {'Port-Channel2': 9214}]"]}, @@ -1347,8 +1348,8 @@ "directedBroadcastEnabled": False, "maxMssIngress": 0, "maxMssEgress": 0, - } - } + }, + }, }, { "interfaces": { @@ -1371,8 +1372,8 @@ "directedBroadcastEnabled": False, "maxMssIngress": 0, "maxMssEgress": 0, - } - } + }, + }, }, ], "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]}, @@ -1403,8 +1404,8 @@ "directedBroadcastEnabled": False, "maxMssIngress": 0, "maxMssEgress": 0, - } - } + }, + }, }, { "interfaces": { @@ -1427,8 +1428,8 @@ "directedBroadcastEnabled": False, "maxMssIngress": 0, "maxMssEgress": 0, - } - } + }, + }, }, ], "inputs": {"interfaces": ["Ethernet1", "Ethernet2"]}, diff --git a/tests/units/anta_tests/test_lanz.py b/tests/units/anta_tests/test_lanz.py index 932d1acc8..bfbf6ae48 100644 --- a/tests/units/anta_tests/test_lanz.py +++ b/tests/units/anta_tests/test_lanz.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Data for testing anta.tests.configuration""" +"""Data for testing anta.tests.lanz.""" + from __future__ import annotations from typing import Any @@ -15,7 +16,7 @@ "test": VerifyLANZ, "eos_data": [{"lanzEnabled": True}], "inputs": None, - "expected": {"result": "success", "messages": ["LANZ is enabled"]}, + "expected": {"result": "success"}, }, { "name": "failure", diff --git a/tests/units/anta_tests/test_logging.py b/tests/units/anta_tests/test_logging.py index 8ac23236e..1e8ee3d3e 100644 --- a/tests/units/anta_tests/test_logging.py +++ b/tests/units/anta_tests/test_logging.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Data for testing anta.tests.logging""" +"""Data for testing anta.tests.logging.""" + from __future__ import annotations from typing import Any @@ -77,7 +78,7 @@ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp Logging to '10.22.10.94' port 911 in VRF MGMT via udp - """ + """, ], "inputs": {"interface": "Management0", "vrf": "MGMT"}, "expected": {"result": "success"}, @@ -92,7 +93,7 @@ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp Logging to '10.22.10.94' port 911 in VRF MGMT via udp - """ + """, ], "inputs": {"interface": "Management0", "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]}, @@ -107,7 +108,7 @@ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp Logging to '10.22.10.94' port 911 in VRF MGMT via udp - """ + """, ], "inputs": {"interface": "Management0", "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Source-interface 'Management0' is not configured in VRF MGMT"]}, @@ -122,7 +123,7 @@ Logging to '10.22.10.93' port 514 in VRF MGMT via tcp Logging to '10.22.10.94' port 911 in VRF MGMT via udp - """ + """, ], "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"}, "expected": {"result": "success"}, @@ -137,7 +138,7 @@ Logging to '10.22.10.103' port 514 in VRF MGMT via tcp Logging to '10.22.10.104' port 911 in VRF MGMT via udp - """ + """, ], "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Syslog servers ['10.22.10.93', '10.22.10.94'] are not configured in VRF MGMT"]}, @@ -152,7 +153,7 @@ Logging to '10.22.10.93' port 514 in VRF default via tcp Logging to '10.22.10.94' port 911 in VRF default via udp - """ + """, ], "inputs": {"hosts": ["10.22.10.92", "10.22.10.93", "10.22.10.94"], "vrf": "MGMT"}, "expected": {"result": "failure", "messages": ["Syslog servers ['10.22.10.93', '10.22.10.94'] are not configured in VRF MGMT"]}, @@ -246,7 +247,7 @@ "name": "failure", "test": VerifyLoggingErrors, "eos_data": [ - "Aug 2 19:57:42 DC1-LEAF1A Mlag: %FWK-3-SOCKET_CLOSE_REMOTE: Connection to Mlag (pid:27200) at tbt://192.168.0.1:4432/+n closed by peer (EOF)" + "Aug 2 19:57:42 DC1-LEAF1A Mlag: %FWK-3-SOCKET_CLOSE_REMOTE: Connection to Mlag (pid:27200) at tbt://192.168.0.1:4432/+n closed by peer (EOF)", ], "inputs": None, "expected": {"result": "failure", "messages": ["Device has reported syslog messages with a severity of ERRORS or higher"]}, diff --git a/tests/units/anta_tests/test_mlag.py b/tests/units/anta_tests/test_mlag.py index 90f3c7a27..ae8ff7cf6 100644 --- a/tests/units/anta_tests/test_mlag.py +++ b/tests/units/anta_tests/test_mlag.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.mlag.py -""" +"""Tests for anta.tests.mlag.py.""" + from __future__ import annotations from typing import Any @@ -25,7 +24,7 @@ "eos_data": [ { "state": "disabled", - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, @@ -47,7 +46,7 @@ { "state": "active", "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 0, "Active-partial": 0, "Active-full": 1}, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -58,7 +57,7 @@ "eos_data": [ { "state": "disabled", - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, @@ -70,7 +69,7 @@ { "state": "active", "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 0, "Active-partial": 1, "Active-full": 1}, - } + }, ], "inputs": None, "expected": { @@ -85,7 +84,7 @@ { "state": "active", "mlagPorts": {"Disabled": 0, "Configured": 0, "Inactive": 1, "Active-partial": 1, "Active-full": 1}, - } + }, ], "inputs": None, "expected": { @@ -106,7 +105,7 @@ "eos_data": [ { "mlagActive": False, - } + }, ], "inputs": None, "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, @@ -117,7 +116,7 @@ "eos_data": [ { "dummy": False, - } + }, ], "inputs": None, "expected": {"result": "error", "messages": ["Incorrect JSON response - 'mlagActive' state was not found"]}, @@ -131,7 +130,7 @@ "interfaceConfiguration": {}, "mlagActive": True, "mlagConnected": True, - } + }, ], "inputs": None, "expected": { @@ -140,7 +139,7 @@ "MLAG config-sanity returned inconsistencies: " "{'globalConfiguration': {'mlag': {'globalParameters': " "{'dual-primary-detection-delay': {'localValue': '0', 'peerValue': '200'}}}}, " - "'interfaceConfiguration': {}}" + "'interfaceConfiguration': {}}", ], }, }, @@ -153,7 +152,7 @@ "interfaceConfiguration": {"trunk-native-vlan mlag30": {"interface": {"Port-Channel30": {"localValue": "123", "peerValue": "3700"}}}}, "mlagActive": True, "mlagConnected": True, - } + }, ], "inputs": None, "expected": { @@ -162,7 +161,7 @@ "MLAG config-sanity returned inconsistencies: " "{'globalConfiguration': {}, " "'interfaceConfiguration': {'trunk-native-vlan mlag30': " - "{'interface': {'Port-Channel30': {'localValue': '123', 'peerValue': '3700'}}}}}" + "{'interface': {'Port-Channel30': {'localValue': '123', 'peerValue': '3700'}}}}}", ], }, }, @@ -179,7 +178,7 @@ "eos_data": [ { "state": "disabled", - } + }, ], "inputs": {"reload_delay": 300, "reload_delay_non_mlag": 330}, "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, @@ -202,7 +201,7 @@ "dualPrimaryMlagRecoveryDelay": 60, "dualPrimaryNonMlagRecoveryDelay": 0, "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "none"}, - } + }, ], "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, "expected": {"result": "success"}, @@ -213,7 +212,7 @@ "eos_data": [ { "state": "disabled", - } + }, ], "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, "expected": {"result": "skipped", "messages": ["MLAG is disabled"]}, @@ -226,7 +225,7 @@ "state": "active", "dualPrimaryDetectionState": "disabled", "dualPrimaryPortsErrdisabled": False, - } + }, ], "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, "expected": {"result": "failure", "messages": ["Dual-primary detection is disabled"]}, @@ -242,7 +241,7 @@ "dualPrimaryMlagRecoveryDelay": 160, "dualPrimaryNonMlagRecoveryDelay": 0, "detail": {"dualPrimaryDetectionDelay": 300, "dualPrimaryAction": "none"}, - } + }, ], "inputs": {"detection_delay": 200, "errdisabled": False, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, "expected": { @@ -254,7 +253,7 @@ "'detail.dualPrimaryAction': 'none', " "'dualPrimaryMlagRecoveryDelay': 160, " "'dualPrimaryNonMlagRecoveryDelay': 0}" - ) + ), ], }, }, @@ -269,7 +268,7 @@ "dualPrimaryMlagRecoveryDelay": 60, "dualPrimaryNonMlagRecoveryDelay": 0, "detail": {"dualPrimaryDetectionDelay": 200, "dualPrimaryAction": "none"}, - } + }, ], "inputs": {"detection_delay": 200, "errdisabled": True, "recovery_delay": 60, "recovery_delay_non_mlag": 0}, "expected": { @@ -281,7 +280,7 @@ "'detail.dualPrimaryAction': 'none', " "'dualPrimaryMlagRecoveryDelay': 60, " "'dualPrimaryNonMlagRecoveryDelay': 0}" - ) + ), ], }, }, diff --git a/tests/units/anta_tests/test_multicast.py b/tests/units/anta_tests/test_multicast.py index 9276a9f1a..a52a1d2ae 100644 --- a/tests/units/anta_tests/test_multicast.py +++ b/tests/units/anta_tests/test_multicast.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Test inputs for anta.tests.multicast""" +"""Test inputs for anta.tests.multicast.""" + from __future__ import annotations from typing import Any @@ -44,7 +45,7 @@ "robustness": 2, "immediateLeave": "enabled", "reportFloodingSwitchPorts": [], - } + }, ], "inputs": {"vlans": {1: True, 42: True}}, "expected": {"result": "success"}, @@ -67,12 +68,12 @@ "maxGroups": 65534, "immediateLeave": "default", "floodingTraffic": True, - } + }, }, "robustness": 2, "immediateLeave": "enabled", "reportFloodingSwitchPorts": [], - } + }, ], "inputs": {"vlans": {42: False}}, "expected": {"result": "success"}, @@ -100,7 +101,7 @@ "robustness": 2, "immediateLeave": "enabled", "reportFloodingSwitchPorts": [], - } + }, ], "inputs": {"vlans": {1: False, 42: False}}, "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is enabled", "Supplied vlan 42 is not present on the device."]}, @@ -128,7 +129,7 @@ "robustness": 2, "immediateLeave": "enabled", "reportFloodingSwitchPorts": [], - } + }, ], "inputs": {"vlans": {1: True}}, "expected": {"result": "failure", "messages": ["IGMP state for vlan 1 is disabled"]}, @@ -143,7 +144,7 @@ "robustness": 2, "immediateLeave": "enabled", "reportFloodingSwitchPorts": [], - } + }, ], "inputs": {"enabled": True}, "expected": {"result": "success"}, @@ -155,7 +156,7 @@ { "reportFlooding": "disabled", "igmpSnoopingState": "disabled", - } + }, ], "inputs": {"enabled": False}, "expected": {"result": "success"}, @@ -167,7 +168,7 @@ { "reportFlooding": "disabled", "igmpSnoopingState": "disabled", - } + }, ], "inputs": {"enabled": True}, "expected": {"result": "failure", "messages": ["IGMP state is not valid: disabled"]}, diff --git a/tests/units/anta_tests/test_profiles.py b/tests/units/anta_tests/test_profiles.py index c0ebb57e5..d58e987c2 100644 --- a/tests/units/anta_tests/test_profiles.py +++ b/tests/units/anta_tests/test_profiles.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.profiles.py -""" +"""Tests for anta.tests.profiles.py.""" + from __future__ import annotations from typing import Any @@ -30,7 +29,7 @@ "name": "success", "test": VerifyTcamProfile, "eos_data": [ - {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "test", "mode": "tcam"}}, "lastProgrammingStatus": {}} + {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "test", "mode": "tcam"}}, "lastProgrammingStatus": {}}, ], "inputs": {"profile": "test"}, "expected": {"result": "success"}, @@ -39,7 +38,7 @@ "name": "failure", "test": VerifyTcamProfile, "eos_data": [ - {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "default", "mode": "tcam"}}, "lastProgrammingStatus": {}} + {"pmfProfiles": {"FixedSystem": {"config": "test", "configType": "System Profile", "status": "default", "mode": "tcam"}}, "lastProgrammingStatus": {}}, ], "inputs": {"profile": "test"}, "expected": {"result": "failure", "messages": ["Incorrect profile running on device: default"]}, diff --git a/tests/units/anta_tests/test_ptp.py b/tests/units/anta_tests/test_ptp.py index 3b3d1d727..ef42a5804 100644 --- a/tests/units/anta_tests/test_ptp.py +++ b/tests/units/anta_tests/test_ptp.py @@ -1,17 +1,19 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Data for testing anta.tests.configuration""" +"""Data for testing anta.tests.ptp.""" + from __future__ import annotations from typing import Any -from anta.tests.ptp import PtpGMStatus, PtpLockStatus, PtpModeStatus, PtpOffset, PtpPortModeStatus +from anta.tests.ptp import VerifyPtpGMStatus, VerifyPtpLockStatus, VerifyPtpModeStatus, VerifyPtpOffset, VerifyPtpPortModeStatus +from tests.lib.anta import test # noqa: F401; pylint: disable=W0611 DATA: list[dict[str, Any]] = [ { "name": "success", - "test": PtpModeStatus, + "test": VerifyPtpModeStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -34,14 +36,21 @@ }, { "name": "failure", - "test": PtpModeStatus, + "test": VerifyPtpModeStatus, "eos_data": [{"ptpMode": "ptpDisabled", "ptpIntfSummaries": {}}], "inputs": None, - "expected": {"result": "failure"}, + "expected": {"result": "failure", "messages": ["The device is not configured as a PTP Boundary Clock: 'ptpDisabled'"]}, + }, + { + "name": "error", + "test": VerifyPtpModeStatus, + "eos_data": [{"ptpIntfSummaries": {}}], + "inputs": None, + "expected": {"result": "error", "messages": ["'ptpMode' variable is not present in the command output"]}, }, { "name": "success", - "test": PtpGMStatus, + "test": VerifyPtpGMStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -62,12 +71,12 @@ }, } ], - "inputs": {"validGM": "0xec:46:70:ff:fe:00:ff:a9"}, + "inputs": {"gmid": "0xec:46:70:ff:fe:00:ff:a8"}, "expected": {"result": "success"}, }, { "name": "failure", - "test": PtpGMStatus, + "test": VerifyPtpGMStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -86,12 +95,24 @@ }, } ], - "inputs": {"validGM": "0xec:46:70:ff:fe:00:ff:a9"}, - "expected": {"result": "failure"}, + "inputs": {"gmid": "0xec:46:70:ff:fe:00:ff:a8"}, + "expected": { + "result": "failure", + "messages": [ + "The device is locked to the following Grandmaster: '0x00:1c:73:ff:ff:0a:00:01', which differ from the expected one.", + ], + }, + }, + { + "name": "error", + "test": VerifyPtpGMStatus, + "eos_data": [{"ptpIntfSummaries": {}}], + "inputs": {"gmid": "0xec:46:70:ff:fe:00:ff:a8"}, + "expected": {"result": "error", "messages": ["'ptpClockSummary' variable is not present in the command output"]}, }, { "name": "success", - "test": PtpLockStatus, + "test": VerifyPtpLockStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -117,7 +138,7 @@ }, { "name": "failure", - "test": PtpLockStatus, + "test": VerifyPtpLockStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -137,11 +158,23 @@ } ], "inputs": None, - "expected": {"result": "failure"}, + "expected": {"result": "failure", "messages": ["The device lock is more than 60s old: 157s"]}, + }, + { + "name": "error", + "test": VerifyPtpLockStatus, + "eos_data": [{"ptpIntfSummaries": {}}], + "inputs": None, + "expected": { + "result": "error", + "messages": [ + "'ptpClockSummary' variable is not present in the command output", + ], + }, }, { "name": "success", - "test": PtpOffset, + "test": VerifyPtpOffset, "eos_data": [ { "monitorEnabled": True, @@ -173,7 +206,7 @@ }, { "name": "failure", - "test": PtpOffset, + "test": VerifyPtpOffset, "eos_data": [ { "monitorEnabled": True, @@ -201,11 +234,26 @@ } ], "inputs": None, - "expected": {"result": "failure"}, + "expected": { + "result": "failure", + "messages": [("The device timing offset from master is greater than +/- 1000ns: {'Ethernet27/1': [1200, -1300]}")], + }, + }, + { + "name": "skipped", + "test": VerifyPtpOffset, + "eos_data": [ + { + "monitorEnabled": True, + "ptpMonitorData": [], + }, + ], + "inputs": None, + "expected": {"result": "skipped", "messages": ["PTP is not configured"]}, }, { "name": "success", - "test": PtpPortModeStatus, + "test": VerifyPtpPortModeStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -243,12 +291,19 @@ }, } ], - "inputs": {"validPortModes": ["psMaster", "psSlave", "psPassive", "psDisabled"]}, + "inputs": None, "expected": {"result": "success"}, }, { "name": "failure", - "test": PtpPortModeStatus, + "test": VerifyPtpPortModeStatus, + "eos_data": [{"ptpIntfSummaries": {}}], + "inputs": None, + "expected": {"result": "failure", "messages": ["No interfaces are PTP enabled"]}, + }, + { + "name": "failure", + "test": VerifyPtpPortModeStatus, "eos_data": [ { "ptpMode": "ptpBoundaryClock", @@ -279,7 +334,7 @@ }, } ], - "inputs": {"validPortModes": ["psMaster", "psSlave", "psPassive", "psDisabled"]}, - "expected": {"result": "failure"}, + "inputs": None, + "expected": {"result": "failure", "messages": ["The following interface(s) are not in a valid PTP state: '['Ethernet53', 'Ethernet1']'"]}, }, ] diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py index 17fa04e32..3ebffea1a 100644 --- a/tests/units/anta_tests/test_security.py +++ b/tests/units/anta_tests/test_security.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.security.py -""" +"""Tests for anta.tests.security.py.""" + from __future__ import annotations from typing import Any @@ -107,7 +106,7 @@ "unixSocketServer": {"configured": False, "running": False}, "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"}, "tlsProtocol": ["1.2"], - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -124,7 +123,7 @@ "unixSocketServer": {"configured": False, "running": False}, "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"}, "tlsProtocol": ["1.2"], - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["eAPI HTTP server is enabled globally"]}, @@ -141,7 +140,7 @@ "unixSocketServer": {"configured": False, "running": False}, "sslProfile": {"name": "API_SSL_Profile", "configured": True, "state": "valid"}, "tlsProtocol": ["1.2"], - } + }, ], "inputs": {"profile": "API_SSL_Profile"}, "expected": {"result": "success"}, @@ -157,7 +156,7 @@ "httpsServer": {"configured": True, "running": True, "port": 443}, "unixSocketServer": {"configured": False, "running": False}, "tlsProtocol": ["1.2"], - } + }, ], "inputs": {"profile": "API_SSL_Profile"}, "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is not configured"]}, @@ -174,7 +173,7 @@ "unixSocketServer": {"configured": False, "running": False}, "sslProfile": {"name": "Wrong_SSL_Profile", "configured": True, "state": "valid"}, "tlsProtocol": ["1.2"], - } + }, ], "inputs": {"profile": "API_SSL_Profile"}, "expected": {"result": "failure", "messages": ["eAPI HTTPS server SSL profile (API_SSL_Profile) is misconfigured or invalid"]}, diff --git a/tests/units/anta_tests/test_services.py b/tests/units/anta_tests/test_services.py index dcd1ee276..ed86e10b3 100644 --- a/tests/units/anta_tests/test_services.py +++ b/tests/units/anta_tests/test_services.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.services.py -""" +"""Tests for anta.tests.services.py.""" + from __future__ import annotations from typing import Any diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index 700968924..b4d31521e 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.snmp.py -""" +"""Tests for anta.tests.snmp.py.""" + from __future__ import annotations from typing import Any diff --git a/tests/units/anta_tests/test_software.py b/tests/units/anta_tests/test_software.py index 6d39c04bf..84e90e8ee 100644 --- a/tests/units/anta_tests/test_software.py +++ b/tests/units/anta_tests/test_software.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Test inputs for anta.tests.hardware""" +"""Test inputs for anta.tests.hardware.""" + from __future__ import annotations from typing import Any @@ -18,7 +19,7 @@ "modelName": "vEOS-lab", "internalVersion": "4.27.0F-24305004.4270F", "version": "4.27.0F", - } + }, ], "inputs": {"versions": ["4.27.0F", "4.28.0F"]}, "expected": {"result": "success"}, @@ -31,7 +32,7 @@ "modelName": "vEOS-lab", "internalVersion": "4.27.0F-24305004.4270F", "version": "4.27.0F", - } + }, ], "inputs": {"versions": ["4.27.1F"]}, "expected": {"result": "failure", "messages": ["device is running version \"4.27.0F\" not in expected versions: ['4.27.1F']"]}, @@ -52,7 +53,7 @@ "TerminAttr-core": {"release": "1", "version": "v1.17.0"}, }, }, - } + }, ], "inputs": {"versions": ["v1.17.0", "v1.18.1"]}, "expected": {"result": "success"}, @@ -73,7 +74,7 @@ "TerminAttr-core": {"release": "1", "version": "v1.17.0"}, }, }, - } + }, ], "inputs": {"versions": ["v1.17.1", "v1.18.1"]}, "expected": {"result": "failure", "messages": ["device is running TerminAttr version v1.17.0 and is not in the allowed list: ['v1.17.1', 'v1.18.1']"]}, diff --git a/tests/units/anta_tests/test_stp.py b/tests/units/anta_tests/test_stp.py index 26f0b90dc..2bfedab9d 100644 --- a/tests/units/anta_tests/test_stp.py +++ b/tests/units/anta_tests/test_stp.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.stp.py -""" +"""Tests for anta.tests.stp.py.""" + from __future__ import annotations from typing import Any @@ -84,8 +83,8 @@ "interfaces": { "Ethernet10": {"bpduSent": 201, "bpduReceived": 0, "bpduTaggedError": 3, "bpduOtherError": 0, "bpduRateLimitCount": 0}, "Ethernet11": {"bpduSent": 99, "bpduReceived": 0, "bpduTaggedError": 0, "bpduOtherError": 6, "bpduRateLimitCount": 0}, - } - } + }, + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["The following interfaces have STP BPDU packet errors: ['Ethernet10', 'Ethernet11']"]}, @@ -162,7 +161,7 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, "VL20": { "rootBridge": { @@ -172,7 +171,7 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, "VL30": { "rootBridge": { @@ -182,10 +181,10 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, - } - } + }, + }, ], "inputs": {"priority": 32768, "instances": [10, 20]}, "expected": {"result": "success"}, @@ -204,7 +203,7 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, "VL20": { "rootBridge": { @@ -214,7 +213,7 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, "VL30": { "rootBridge": { @@ -224,10 +223,10 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, - } - } + }, + }, ], "inputs": {"priority": 32768}, "expected": {"result": "success"}, @@ -246,10 +245,10 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } - } - } - } + }, + }, + }, + }, ], "inputs": {"priority": 16384, "instances": [0]}, "expected": {"result": "success"}, @@ -268,10 +267,10 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } - } - } - } + }, + }, + }, + }, ], "inputs": {"priority": 32768, "instances": [0]}, "expected": {"result": "failure", "messages": ["Unsupported STP instance type: WRONG0"]}, @@ -297,7 +296,7 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, "VL20": { "rootBridge": { @@ -307,7 +306,7 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, "VL30": { "rootBridge": { @@ -317,10 +316,10 @@ "helloTime": 2.0, "maxAge": 20, "forwardDelay": 15, - } + }, }, - } - } + }, + }, ], "inputs": {"priority": 32768, "instances": [10, 20, 30]}, "expected": {"result": "failure", "messages": ["The following instance(s) have the wrong STP root priority configured: ['VL20', 'VL30']"]}, diff --git a/tests/units/anta_tests/test_system.py b/tests/units/anta_tests/test_system.py index 62260fa7a..6965461d6 100644 --- a/tests/units/anta_tests/test_system.py +++ b/tests/units/anta_tests/test_system.py @@ -1,7 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -"""Test inputs for anta.tests.system""" +"""Test inputs for anta.tests.system.""" + from __future__ import annotations from typing import Any @@ -46,10 +47,15 @@ "eos_data": [ { "resetCauses": [ - {"recommendedAction": "No action necessary.", "description": "Reload requested by the user.", "timestamp": 1683186892.0, "debugInfoIsDir": False} + { + "recommendedAction": "No action necessary.", + "description": "Reload requested by the user.", + "timestamp": 1683186892.0, + "debugInfoIsDir": False, + }, ], "full": False, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -61,10 +67,10 @@ "eos_data": [ { "resetCauses": [ - {"recommendedAction": "No action necessary.", "description": "Reload after crash.", "timestamp": 1683186892.0, "debugInfoIsDir": False} + {"recommendedAction": "No action necessary.", "description": "Reload after crash.", "timestamp": 1683186892.0, "debugInfoIsDir": False}, ], "full": False, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Reload cause is: 'Reload after crash.'"]}, @@ -125,7 +131,7 @@ ===> /var/log/agents/Acl-830 Fri Jul 7 15:07:00 2023 <=== ===== Output from /usr/bin/Acl [] (PID=830) started Jul 7 15:06:10.871700 === EntityManager::doBackoff waiting for remote sysdb version ...................ok -""" +""", ], "inputs": None, "expected": { @@ -158,9 +164,9 @@ "activeTime": 360, "virtMem": "6644", "sharedMem": "3996", - } + }, }, - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -185,9 +191,9 @@ "activeTime": 360, "virtMem": "6644", "sharedMem": "3996", - } + }, }, - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Device has reported a high CPU utilization: 75.2%"]}, @@ -203,7 +209,7 @@ "memTotal": 2004568, "memFree": 879004, "version": "4.27.3F", - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -219,7 +225,7 @@ "memTotal": 2004568, "memFree": 89004, "version": "4.27.3F", - } + }, ], "inputs": None, "expected": {"result": "failure", "messages": ["Device has reported a high memory usage: 95.56%"]}, @@ -233,7 +239,7 @@ none 294M 78M 217M 27% / none 294M 78M 217M 27% /.overlay /dev/loop0 461M 461M 0 100% /rootfs-i386 -""" +""", ], "inputs": None, "expected": {"result": "success"}, @@ -247,7 +253,7 @@ none 294M 78M 217M 27% / none 294M 78M 217M 84% /.overlay /dev/loop0 461M 461M 0 100% /rootfs-i386 -""" +""", ], "inputs": None, "expected": { @@ -264,7 +270,7 @@ "eos_data": [ """synchronised poll interval unknown -""" +""", ], "inputs": None, "expected": {"result": "success"}, @@ -275,7 +281,7 @@ "eos_data": [ """unsynchronised poll interval unknown -""" +""", ], "inputs": None, "expected": {"result": "failure", "messages": ["The device is not synchronized with the configured NTP server(s): 'unsynchronised'"]}, diff --git a/tests/units/anta_tests/test_vlan.py b/tests/units/anta_tests/test_vlan.py index 93398f664..53bf92f94 100644 --- a/tests/units/anta_tests/test_vlan.py +++ b/tests/units/anta_tests/test_vlan.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.vlan.py -""" +"""Tests for anta.tests.vlan.py.""" + from __future__ import annotations from typing import Any diff --git a/tests/units/anta_tests/test_vxlan.py b/tests/units/anta_tests/test_vxlan.py index 2a9a875e7..f450897a6 100644 --- a/tests/units/anta_tests/test_vxlan.py +++ b/tests/units/anta_tests/test_vxlan.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tests.vxlan.py -""" +"""Tests for anta.tests.vxlan.py.""" + from __future__ import annotations from typing import Any @@ -107,7 +106,7 @@ }, }, "warnings": [], - } + }, ], "inputs": None, "expected": {"result": "success"}, @@ -172,7 +171,7 @@ }, }, "warnings": ["Your configuration contains warnings. This does not mean misconfigurations. But you may wish to re-check your configurations."], - } + }, ], "inputs": None, "expected": { @@ -184,7 +183,7 @@ "'No VLAN-VNI mapping in Vxlan1'}, {'name': 'Flood List', 'checkPass': False, 'hasWarning': True, 'detail': " "'No VXLAN VLANs in Vxlan1'}, {'name': 'Routing', 'checkPass': True, 'hasWarning': False, 'detail': ''}, {'name': " "'VNI VRF ACL', 'checkPass': True, 'hasWarning': False, 'detail': ''}, {'name': 'VRF-VNI Dynamic VLAN', 'checkPass': True, " - "'hasWarning': False, 'detail': ''}, {'name': 'Decap VRF-VNI Map', 'checkPass': True, 'hasWarning': False, 'detail': ''}]}}" + "'hasWarning': False, 'detail': ''}, {'name': 'Decap VRF-VNI Map', 'checkPass': True, 'hasWarning': False, 'detail': ''}]}}", ], }, }, @@ -203,12 +202,12 @@ "vxlanIntfs": { "Vxlan1": { "vniBindings": { - "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}, }, "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, - } - } - } + }, + }, + }, ], "inputs": {"bindings": {10020: 20, 500: 1199}}, "expected": {"result": "success"}, @@ -221,12 +220,12 @@ "vxlanIntfs": { "Vxlan1": { "vniBindings": { - "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + "10020": {"vlan": 20, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}, }, "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, - } - } - } + }, + }, + }, ], "inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}}, "expected": {"result": "failure", "messages": ["The following VNI(s) have no binding: ['10010']"]}, @@ -239,12 +238,12 @@ "vxlanIntfs": { "Vxlan1": { "vniBindings": { - "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}, }, "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, - } - } - } + }, + }, + }, ], "inputs": {"bindings": {10020: 20, 500: 1199}}, "expected": {"result": "failure", "messages": ["The following VNI(s) have the wrong VLAN binding: [{'10020': 30}]"]}, @@ -257,12 +256,12 @@ "vxlanIntfs": { "Vxlan1": { "vniBindings": { - "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}} + "10020": {"vlan": 30, "dynamicVlan": False, "source": "static", "interfaces": {"Ethernet31": {"dot1q": 0}, "Vxlan1": {"dot1q": 20}}}, }, "vniBindingsToVrf": {"500": {"vrfName": "PROD", "vlan": 1199, "source": "evpn"}}, - } - } - } + }, + }, + }, ], "inputs": {"bindings": {10010: 10, 10020: 20, 500: 1199}}, "expected": { diff --git a/tests/units/cli/__init__.py b/tests/units/cli/__init__.py index e772bee41..1d4cf6c55 100644 --- a/tests/units/cli/__init__.py +++ b/tests/units/cli/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.cli submodule.""" diff --git a/tests/units/cli/check/__init__.py b/tests/units/cli/check/__init__.py index e772bee41..a116af468 100644 --- a/tests/units/cli/check/__init__.py +++ b/tests/units/cli/check/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.cli.check submodule.""" diff --git a/tests/units/cli/check/test__init__.py b/tests/units/cli/check/test__init__.py index a3a770b7e..2501dc869 100644 --- a/tests/units/cli/check/test__init__.py +++ b/tests/units/cli/check/test__init__.py @@ -1,30 +1,28 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.check -""" +"""Tests for anta.cli.check.""" + from __future__ import annotations -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode +if TYPE_CHECKING: + from click.testing import CliRunner + def test_anta_check(click_runner: CliRunner) -> None: - """ - Test anta check - """ + """Test anta check.""" result = click_runner.invoke(anta, ["check"]) assert result.exit_code == ExitCode.OK assert "Usage: anta check" in result.output def test_anta_check_help(click_runner: CliRunner) -> None: - """ - Test anta check --help - """ + """Test anta check --help.""" result = click_runner.invoke(anta, ["check", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta check" in result.output diff --git a/tests/units/cli/check/test_commands.py b/tests/units/cli/check/test_commands.py index 746b31594..11c2b5ff2 100644 --- a/tests/units/cli/check/test_commands.py +++ b/tests/units/cli/check/test_commands.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.check.commands -""" +"""Tests for anta.cli.check.commands.""" + from __future__ import annotations from pathlib import Path @@ -21,7 +20,7 @@ @pytest.mark.parametrize( - "catalog_path, expected_exit, expected_output", + ("catalog_path", "expected_exit", "expected_output"), [ pytest.param("ghost_catalog.yml", ExitCode.USAGE_ERROR, "Error: Invalid value for '--catalog'", id="catalog does not exist"), pytest.param("test_catalog_with_undefined_module.yml", ExitCode.USAGE_ERROR, "Test catalog is invalid!", id="catalog is not valid"), @@ -29,9 +28,7 @@ ], ) def test_catalog(click_runner: CliRunner, catalog_path: Path, expected_exit: int, expected_output: str) -> None: - """ - Test `anta check catalog -c catalog - """ + """Test `anta check catalog -c catalog.""" result = click_runner.invoke(anta, ["check", "catalog", "-c", str(DATA_DIR / catalog_path)]) assert result.exit_code == expected_exit assert expected_output in result.output diff --git a/tests/units/cli/debug/__init__.py b/tests/units/cli/debug/__init__.py index e772bee41..ccce49cb9 100644 --- a/tests/units/cli/debug/__init__.py +++ b/tests/units/cli/debug/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.cli.debug submodule.""" diff --git a/tests/units/cli/debug/test__init__.py b/tests/units/cli/debug/test__init__.py index 062182dfb..fd3663fa9 100644 --- a/tests/units/cli/debug/test__init__.py +++ b/tests/units/cli/debug/test__init__.py @@ -1,30 +1,28 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.debug -""" +"""Tests for anta.cli.debug.""" + from __future__ import annotations -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode +if TYPE_CHECKING: + from click.testing import CliRunner + def test_anta_debug(click_runner: CliRunner) -> None: - """ - Test anta debug - """ + """Test anta debug.""" result = click_runner.invoke(anta, ["debug"]) assert result.exit_code == ExitCode.OK assert "Usage: anta debug" in result.output def test_anta_debug_help(click_runner: CliRunner) -> None: - """ - Test anta debug --help - """ + """Test anta debug --help.""" result = click_runner.invoke(anta, ["debug", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta debug" in result.output diff --git a/tests/units/cli/debug/test_commands.py b/tests/units/cli/debug/test_commands.py index 6d9ac29a1..76c3648e3 100644 --- a/tests/units/cli/debug/test_commands.py +++ b/tests/units/cli/debug/test_commands.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.debug.commands -""" +"""Tests for anta.cli.debug.commands.""" + from __future__ import annotations from typing import TYPE_CHECKING, Literal @@ -18,7 +17,7 @@ @pytest.mark.parametrize( - "command, ofmt, version, revision, device, failed", + ("command", "ofmt", "version", "revision", "device", "failed"), [ pytest.param("show version", "json", None, None, "dummy", False, id="json command"), pytest.param("show version", "text", None, None, "dummy", False, id="text command"), @@ -29,11 +28,15 @@ ], ) def test_run_cmd( - click_runner: CliRunner, command: str, ofmt: Literal["json", "text"], version: Literal["1", "latest"] | None, revision: int | None, device: str, failed: bool + click_runner: CliRunner, + command: str, + ofmt: Literal["json", "text"], + version: Literal["1", "latest"] | None, + revision: int | None, + device: str, + failed: bool, ) -> None: - """ - Test `anta debug run-cmd` - """ + """Test `anta debug run-cmd`.""" # pylint: disable=too-many-arguments cli_args = ["-l", "debug", "debug", "run-cmd", "--command", command, "--device", device] diff --git a/tests/units/cli/exec/__init__.py b/tests/units/cli/exec/__init__.py index e772bee41..4ed48bceb 100644 --- a/tests/units/cli/exec/__init__.py +++ b/tests/units/cli/exec/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.cli.exec submodule.""" diff --git a/tests/units/cli/exec/test__init__.py b/tests/units/cli/exec/test__init__.py index f8ad36542..124d4af0d 100644 --- a/tests/units/cli/exec/test__init__.py +++ b/tests/units/cli/exec/test__init__.py @@ -1,30 +1,28 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.exec -""" +"""Tests for anta.cli.exec.""" + from __future__ import annotations -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode +if TYPE_CHECKING: + from click.testing import CliRunner + def test_anta_exec(click_runner: CliRunner) -> None: - """ - Test anta exec - """ + """Test anta exec.""" result = click_runner.invoke(anta, ["exec"]) assert result.exit_code == ExitCode.OK assert "Usage: anta exec" in result.output def test_anta_exec_help(click_runner: CliRunner) -> None: - """ - Test anta exec --help - """ + """Test anta exec --help.""" result = click_runner.invoke(anta, ["exec", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta exec" in result.output diff --git a/tests/units/cli/exec/test_commands.py b/tests/units/cli/exec/test_commands.py index f96d7f610..4a72d6387 100644 --- a/tests/units/cli/exec/test_commands.py +++ b/tests/units/cli/exec/test_commands.py @@ -1,9 +1,7 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.exec.commands -""" +"""Tests for anta.cli.exec.commands.""" from __future__ import annotations @@ -21,27 +19,21 @@ def test_clear_counters_help(click_runner: CliRunner) -> None: - """ - Test `anta exec clear-counters --help` - """ + """Test `anta exec clear-counters --help`.""" result = click_runner.invoke(clear_counters, ["--help"]) assert result.exit_code == 0 assert "Usage" in result.output def test_snapshot_help(click_runner: CliRunner) -> None: - """ - Test `anta exec snapshot --help` - """ + """Test `anta exec snapshot --help`.""" result = click_runner.invoke(snapshot, ["--help"]) assert result.exit_code == 0 assert "Usage" in result.output def test_collect_tech_support_help(click_runner: CliRunner) -> None: - """ - Test `anta exec collect-tech-support --help` - """ + """Test `anta exec collect-tech-support --help`.""" result = click_runner.invoke(collect_tech_support, ["--help"]) assert result.exit_code == 0 assert "Usage" in result.output @@ -55,9 +47,7 @@ def test_collect_tech_support_help(click_runner: CliRunner) -> None: ], ) def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None: - """ - Test `anta exec clear-counters` - """ + """Test `anta exec clear-counters`.""" cli_args = ["exec", "clear-counters"] if tags is not None: cli_args.extend(["--tags", tags]) @@ -69,7 +59,7 @@ def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None: @pytest.mark.parametrize( - "commands_path, tags", + ("commands_path", "tags"), [ pytest.param(None, None, id="missing command list"), pytest.param(Path("/I/do/not/exist"), None, id="wrong path for command_list"), @@ -78,9 +68,7 @@ def test_clear_counters(click_runner: CliRunner, tags: str | None) -> None: ], ) def test_snapshot(tmp_path: Path, click_runner: CliRunner, commands_path: Path | None, tags: str | None) -> None: - """ - Test `anta exec snapshot` - """ + """Test `anta exec snapshot`.""" cli_args = ["exec", "snapshot", "--output", str(tmp_path)] # Need to mock datetetime if commands_path is not None: @@ -99,7 +87,7 @@ def test_snapshot(tmp_path: Path, click_runner: CliRunner, commands_path: Path | @pytest.mark.parametrize( - "output, latest, configure, tags", + ("output", "latest", "configure", "tags"), [ pytest.param(None, None, False, None, id="no params"), pytest.param("/tmp/dummy", None, False, None, id="with output"), @@ -109,9 +97,7 @@ def test_snapshot(tmp_path: Path, click_runner: CliRunner, commands_path: Path | ], ) def test_collect_tech_support(click_runner: CliRunner, output: str | None, latest: str | None, configure: bool | None, tags: str | None) -> None: - """ - Test `anta exec collect-tech-support` - """ + """Test `anta exec collect-tech-support`.""" cli_args = ["exec", "collect-tech-support"] if output is not None: cli_args.extend(["--output", output]) diff --git a/tests/units/cli/exec/test_utils.py b/tests/units/cli/exec/test_utils.py index 6df1c860c..adf1c7407 100644 --- a/tests/units/cli/exec/test_utils.py +++ b/tests/units/cli/exec/test_utils.py @@ -1,9 +1,7 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.exec.utils -""" +"""Tests for anta.cli.exec.utils.""" from __future__ import annotations @@ -12,40 +10,59 @@ import pytest -from anta.cli.exec.utils import clear_counters_utils # , collect_commands, collect_scheduled_show_tech -from anta.device import AntaDevice -from anta.inventory import AntaInventory +from anta.cli.exec.utils import ( + clear_counters_utils, +) from anta.models import AntaCommand +# , collect_commands, collect_scheduled_show_tech + if TYPE_CHECKING: - from pytest import LogCaptureFixture + from anta.device import AntaDevice + from anta.inventory import AntaInventory -# TODO complete test cases -@pytest.mark.asyncio +# TODO: complete test cases +@pytest.mark.asyncio() @pytest.mark.parametrize( - "inventory_state, per_device_command_output, tags", + ("inventory_state", "per_device_command_output", "tags"), [ pytest.param( - {"dummy": {"is_online": False}, "dummy2": {"is_online": False}, "dummy3": {"is_online": False}}, + { + "dummy": {"is_online": False}, + "dummy2": {"is_online": False}, + "dummy3": {"is_online": False}, + }, {}, None, id="no_connected_device", ), pytest.param( - {"dummy": {"is_online": True, "hw_model": "cEOSLab"}, "dummy2": {"is_online": True, "hw_model": "vEOS-lab"}, "dummy3": {"is_online": False}}, + { + "dummy": {"is_online": True, "hw_model": "cEOSLab"}, + "dummy2": {"is_online": True, "hw_model": "vEOS-lab"}, + "dummy3": {"is_online": False}, + }, {}, None, id="cEOSLab and vEOS-lab devices", ), pytest.param( - {"dummy": {"is_online": True}, "dummy2": {"is_online": True}, "dummy3": {"is_online": False}}, + { + "dummy": {"is_online": True}, + "dummy2": {"is_online": True}, + "dummy3": {"is_online": False}, + }, {"dummy": None}, # None means the command failed to collect None, id="device with error", ), pytest.param( - {"dummy": {"is_online": True}, "dummy2": {"is_online": True}, "dummy3": {"is_online": True}}, + { + "dummy": {"is_online": True}, + "dummy2": {"is_online": True}, + "dummy3": {"is_online": True}, + }, {}, ["spine"], id="tags", @@ -53,29 +70,23 @@ ], ) async def test_clear_counters_utils( - caplog: LogCaptureFixture, + caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory, inventory_state: dict[str, Any], per_device_command_output: dict[str, Any], tags: list[str] | None, ) -> None: - """ - Test anta.cli.exec.utils.clear_counters_utils - """ + """Test anta.cli.exec.utils.clear_counters_utils.""" async def mock_connect_inventory() -> None: - """ - mocking connect_inventory coroutine - """ + """Mock connect_inventory coroutine.""" for name, device in test_inventory.items(): device.is_online = inventory_state[name].get("is_online", True) device.established = inventory_state[name].get("established", device.is_online) device.hw_model = inventory_state[name].get("hw_model", "dummy") async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: - """ - mocking collect coroutine - """ + """Mock collect coroutine.""" command.output = per_device_command_output.get(self.name, "") # Need to patch the child device class @@ -83,7 +94,6 @@ async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: "anta.inventory.AntaInventory.connect_inventory", side_effect=mock_connect_inventory, ) as mocked_connect_inventory: - print(mocked_collect) mocked_collect.side_effect = dummy_collect await clear_counters_utils(test_inventory, tags=tags) @@ -96,32 +106,28 @@ async def dummy_collect(self: AntaDevice, command: AntaCommand) -> None: calls.append( call( device, - **{ - "command": AntaCommand( - command="clear counters", - version="latest", - revision=None, - ofmt="json", - output=per_device_command_output.get(device.name, ""), - errors=[], - ) - }, - ) + command=AntaCommand( + command="clear counters", + version="latest", + revision=None, + ofmt="json", + output=per_device_command_output.get(device.name, ""), + errors=[], + ), + ), ) if device.hw_model not in ["cEOSLab", "vEOS-lab"]: calls.append( call( device, - **{ - "command": AntaCommand( - command="clear hardware counter drop", - version="latest", - revision=None, - ofmt="json", - output=per_device_command_output.get(device.name, ""), - ) - }, - ) + command=AntaCommand( + command="clear hardware counter drop", + version="latest", + revision=None, + ofmt="json", + output=per_device_command_output.get(device.name, ""), + ), + ), ) mocked_collect.assert_has_awaits(calls) # Check error diff --git a/tests/units/cli/get/__init__.py b/tests/units/cli/get/__init__.py index e772bee41..5517deda4 100644 --- a/tests/units/cli/get/__init__.py +++ b/tests/units/cli/get/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.cli.get submodule.""" diff --git a/tests/units/cli/get/test__init__.py b/tests/units/cli/get/test__init__.py index b18ef8801..a6a0c3c46 100644 --- a/tests/units/cli/get/test__init__.py +++ b/tests/units/cli/get/test__init__.py @@ -1,30 +1,28 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.get -""" +"""Tests for anta.cli.get.""" + from __future__ import annotations -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode +if TYPE_CHECKING: + from click.testing import CliRunner + def test_anta_get(click_runner: CliRunner) -> None: - """ - Test anta get - """ + """Test anta get.""" result = click_runner.invoke(anta, ["get"]) assert result.exit_code == ExitCode.OK assert "Usage: anta get" in result.output def test_anta_get_help(click_runner: CliRunner) -> None: - """ - Test anta get --help - """ + """Test anta get --help.""" result = click_runner.invoke(anta, ["get", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta get" in result.output diff --git a/tests/units/cli/get/test_commands.py b/tests/units/cli/get/test_commands.py index aa6dc4fc4..67d49bc16 100644 --- a/tests/units/cli/get/test_commands.py +++ b/tests/units/cli/get/test_commands.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.get.commands -""" +"""Tests for anta.cli.get.commands.""" + from __future__ import annotations import filecmp @@ -12,7 +11,6 @@ from unittest.mock import ANY, patch import pytest -from cvprac.cvp_client import CvpClient from cvprac.cvp_client_errors import CvpApiError from anta.cli import anta @@ -20,12 +18,13 @@ if TYPE_CHECKING: from click.testing import CliRunner + from cvprac.cvp_client import CvpClient DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data" @pytest.mark.parametrize( - "cvp_container, cvp_connect_failure", + ("cvp_container", "cvp_connect_failure"), [ pytest.param(None, False, id="all devices"), pytest.param("custom_container", False, id="custom container"), @@ -38,27 +37,40 @@ def test_from_cvp( cvp_container: str | None, cvp_connect_failure: bool, ) -> None: - """ - Test `anta get from-cvp` + """Test `anta get from-cvp`. This test verifies that username and password are NOT mandatory to run this command """ output: Path = tmp_path / "output.yml" - cli_args = ["get", "from-cvp", "--output", str(output), "--host", "42.42.42.42", "--username", "anta", "--password", "anta"] + cli_args = [ + "get", + "from-cvp", + "--output", + str(output), + "--host", + "42.42.42.42", + "--username", + "anta", + "--password", + "anta", + ] if cvp_container is not None: cli_args.extend(["--container", cvp_container]) - def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: - # pylint: disable=unused-argument + def mock_cvp_connect(_self: CvpClient, *_args: str, **_kwargs: str) -> None: if cvp_connect_failure: raise CvpApiError(msg="mocked CvpApiError") # always get a token with patch("anta.cli.get.commands.get_cv_token", return_value="dummy_token"), patch( - "cvprac.cvp_client.CvpClient.connect", autospec=True, side_effect=mock_cvp_connect + "cvprac.cvp_client.CvpClient.connect", + autospec=True, + side_effect=mock_cvp_connect, ) as mocked_cvp_connect, patch("cvprac.cvp_client.CvpApi.get_inventory", autospec=True, return_value=[]) as mocked_get_inventory, patch( - "cvprac.cvp_client.CvpApi.get_devices_in_container", autospec=True, return_value=[] + "cvprac.cvp_client.CvpApi.get_devices_in_container", + autospec=True, + return_value=[], ) as mocked_get_devices_in_container: result = click_runner.invoke(anta, cli_args) @@ -79,12 +91,24 @@ def mock_cvp_connect(self: CvpClient, *args: str, **kwargs: str) -> None: @pytest.mark.parametrize( - "ansible_inventory, ansible_group, expected_exit, expected_log", + ("ansible_inventory", "ansible_group", "expected_exit", "expected_log"), [ pytest.param("ansible_inventory.yml", None, ExitCode.OK, None, id="no group"), pytest.param("ansible_inventory.yml", "ATD_LEAFS", ExitCode.OK, None, id="group found"), - pytest.param("ansible_inventory.yml", "DUMMY", ExitCode.USAGE_ERROR, "Group DUMMY not found in Ansible inventory", id="group not found"), - pytest.param("empty_ansible_inventory.yml", None, ExitCode.USAGE_ERROR, "is empty", id="empty inventory"), + pytest.param( + "ansible_inventory.yml", + "DUMMY", + ExitCode.USAGE_ERROR, + "Group DUMMY not found in Ansible inventory", + id="group not found", + ), + pytest.param( + "empty_ansible_inventory.yml", + None, + ExitCode.USAGE_ERROR, + "is empty", + id="empty inventory", + ), ], ) def test_from_ansible( @@ -95,8 +119,7 @@ def test_from_ansible( expected_exit: int, expected_log: str | None, ) -> None: - """ - Test `anta get from-ansible` + """Test `anta get from-ansible`. This test verifies: * the parsing of an ansible-inventory @@ -107,7 +130,14 @@ def test_from_ansible( output: Path = tmp_path / "output.yml" ansible_inventory_path = DATA_DIR / ansible_inventory # Init cli_args - cli_args = ["get", "from-ansible", "--output", str(output), "--ansible-inventory", str(ansible_inventory_path)] + cli_args = [ + "get", + "from-ansible", + "--output", + str(output), + "--ansible-inventory", + str(ansible_inventory_path), + ] # Set --ansible-group if ansible_group is not None: @@ -122,14 +152,30 @@ def test_from_ansible( assert expected_log in result.output else: assert output.exists() - # TODO check size of generated inventory to validate the group functionality! + # TODO: check size of generated inventory to validate the group functionality! @pytest.mark.parametrize( - "env_set, overwrite, is_tty, prompt, expected_exit, expected_log", + ("env_set", "overwrite", "is_tty", "prompt", "expected_exit", "expected_log"), [ - pytest.param(True, False, True, "y", ExitCode.OK, "", id="no-overwrite-tty-init-prompt-yes"), - pytest.param(True, False, True, "N", ExitCode.INTERNAL_ERROR, "Aborted", id="no-overwrite-tty-init-prompt-no"), + pytest.param( + True, + False, + True, + "y", + ExitCode.OK, + "", + id="no-overwrite-tty-init-prompt-yes", + ), + pytest.param( + True, + False, + True, + "N", + ExitCode.INTERNAL_ERROR, + "Aborted", + id="no-overwrite-tty-init-prompt-no", + ), pytest.param( True, False, @@ -159,8 +205,7 @@ def test_from_ansible_overwrite( expected_log: str | None, ) -> None: # pylint: disable=too-many-arguments - """ - Test `anta get from-ansible` overwrite mechanism + """Test `anta get from-ansible` overwrite mechanism. The test uses a static ansible-inventory and output as these are tested in other functions @@ -177,7 +222,12 @@ def test_from_ansible_overwrite( ansible_inventory_path = DATA_DIR / "ansible_inventory.yml" expected_anta_inventory_path = DATA_DIR / "expected_anta_inventory.yml" tmp_output = tmp_path / "output.yml" - cli_args = ["get", "from-ansible", "--ansible-inventory", str(ansible_inventory_path)] + cli_args = [ + "get", + "from-ansible", + "--ansible-inventory", + str(ansible_inventory_path), + ] if env_set: tmp_inv = Path(str(temp_env["ANTA_INVENTORY"])) diff --git a/tests/units/cli/get/test_utils.py b/tests/units/cli/get/test_utils.py index b33588015..8d24f186f 100644 --- a/tests/units/cli/get/test_utils.py +++ b/tests/units/cli/get/test_utils.py @@ -1,12 +1,11 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.get.utils -""" +"""Tests for anta.cli.get.utils.""" + from __future__ import annotations -from contextlib import nullcontext +from contextlib import AbstractContextManager, nullcontext from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -21,9 +20,7 @@ def test_get_cv_token() -> None: - """ - Test anta.get.utils.get_cv_token - """ + """Test anta.get.utils.get_cv_token.""" ip = "42.42.42.42" username = "ant" password = "formica" @@ -72,9 +69,7 @@ def test_get_cv_token() -> None: ], ) def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any]]) -> None: - """ - Test anta.get.utils.create_inventory_from_cvp - """ + """Test anta.get.utils.create_inventory_from_cvp.""" output = tmp_path / "output.yml" create_inventory_from_cvp(inventory, output) @@ -86,19 +81,41 @@ def test_create_inventory_from_cvp(tmp_path: Path, inventory: list[dict[str, Any @pytest.mark.parametrize( - "inventory_filename, ansible_group, expected_raise, expected_inv_length", + ("inventory_filename", "ansible_group", "expected_raise", "expected_inv_length"), [ pytest.param("ansible_inventory.yml", None, nullcontext(), 7, id="no group"), pytest.param("ansible_inventory.yml", "ATD_LEAFS", nullcontext(), 4, id="group found"), - pytest.param("ansible_inventory.yml", "DUMMY", pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"), 0, id="group not found"), - pytest.param("empty_ansible_inventory.yml", None, pytest.raises(ValueError, match="Ansible inventory .* is empty"), 0, id="empty inventory"), - pytest.param("wrong_ansible_inventory.yml", None, pytest.raises(ValueError, match="Could not parse"), 0, id="os error inventory"), + pytest.param( + "ansible_inventory.yml", + "DUMMY", + pytest.raises(ValueError, match="Group DUMMY not found in Ansible inventory"), + 0, + id="group not found", + ), + pytest.param( + "empty_ansible_inventory.yml", + None, + pytest.raises(ValueError, match="Ansible inventory .* is empty"), + 0, + id="empty inventory", + ), + pytest.param( + "wrong_ansible_inventory.yml", + None, + pytest.raises(ValueError, match="Could not parse"), + 0, + id="os error inventory", + ), ], ) -def test_create_inventory_from_ansible(tmp_path: Path, inventory_filename: Path, ansible_group: str | None, expected_raise: Any, expected_inv_length: int) -> None: - """ - Test anta.get.utils.create_inventory_from_ansible - """ +def test_create_inventory_from_ansible( + tmp_path: Path, + inventory_filename: Path, + ansible_group: str | None, + expected_raise: AbstractContextManager[Exception], + expected_inv_length: int, +) -> None: + """Test anta.get.utils.create_inventory_from_ansible.""" target_file = tmp_path / "inventory.yml" inventory_file_path = DATA_DIR / inventory_filename diff --git a/tests/units/cli/nrfu/__init__.py b/tests/units/cli/nrfu/__init__.py index e772bee41..db71b4d69 100644 --- a/tests/units/cli/nrfu/__init__.py +++ b/tests/units/cli/nrfu/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.cli.nrfu submodule.""" diff --git a/tests/units/cli/nrfu/test__init__.py b/tests/units/cli/nrfu/test__init__.py index fea641caa..052c7c323 100644 --- a/tests/units/cli/nrfu/test__init__.py +++ b/tests/units/cli/nrfu/test__init__.py @@ -1,33 +1,31 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.nrfu -""" +"""Tests for anta.cli.nrfu.""" + from __future__ import annotations -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode from tests.lib.utils import default_anta_env +if TYPE_CHECKING: + from click.testing import CliRunner + # TODO: write unit tests for ignore-status and ignore-error def test_anta_nrfu_help(click_runner: CliRunner) -> None: - """ - Test anta nrfu --help - """ + """Test anta nrfu --help.""" result = click_runner.invoke(anta, ["nrfu", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta nrfu" in result.output def test_anta_nrfu(click_runner: CliRunner) -> None: - """ - Test anta nrfu, catalog is given via env - """ + """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu"]) assert result.exit_code == ExitCode.OK assert "ANTA Inventory contains 3 devices" in result.output @@ -35,9 +33,7 @@ def test_anta_nrfu(click_runner: CliRunner) -> None: def test_anta_password_required(click_runner: CliRunner) -> None: - """ - Test that password is provided - """ + """Test that password is provided.""" env = default_anta_env() env["ANTA_PASSWORD"] = None result = click_runner.invoke(anta, ["nrfu"], env=env) @@ -47,9 +43,7 @@ def test_anta_password_required(click_runner: CliRunner) -> None: def test_anta_password(click_runner: CliRunner) -> None: - """ - Test that password can be provided either via --password or --prompt - """ + """Test that password can be provided either via --password or --prompt.""" env = default_anta_env() env["ANTA_PASSWORD"] = None result = click_runner.invoke(anta, ["nrfu", "--password", "secret"], env=env) @@ -59,9 +53,7 @@ def test_anta_password(click_runner: CliRunner) -> None: def test_anta_enable_password(click_runner: CliRunner) -> None: - """ - Test that enable password can be provided either via --enable-password or --prompt - """ + """Test that enable password can be provided either via --enable-password or --prompt.""" # Both enable and enable-password result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "secret"]) assert result.exit_code == ExitCode.OK @@ -78,7 +70,6 @@ def test_anta_enable_password(click_runner: CliRunner) -> None: assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output assert result.exit_code == ExitCode.OK - # enable and enable-password and prompt (redundant) result = click_runner.invoke(anta, ["nrfu", "--enable", "--enable-password", "blah", "--prompt"], input="y\npassword\npassword\n") assert "Is a password required to enter EOS privileged EXEC mode? [y/N]:" not in result.output assert "Please enter a password to enter EOS privileged EXEC mode" not in result.output @@ -91,17 +82,13 @@ def test_anta_enable_password(click_runner: CliRunner) -> None: def test_anta_enable_alone(click_runner: CliRunner) -> None: - """ - Test that enable can be provided either without enable-password - """ + """Test that enable can be provided either without enable-password.""" result = click_runner.invoke(anta, ["nrfu", "--enable"]) assert result.exit_code == ExitCode.OK def test_disable_cache(click_runner: CliRunner) -> None: - """ - Test that disable_cache is working on inventory - """ + """Test that disable_cache is working on inventory.""" result = click_runner.invoke(anta, ["nrfu", "--disable-cache"]) stdout_lines = result.stdout.split("\n") # All caches should be disabled from the inventory diff --git a/tests/units/cli/nrfu/test_commands.py b/tests/units/cli/nrfu/test_commands.py index 4639671f0..5fcf2fbeb 100644 --- a/tests/units/cli/nrfu/test_commands.py +++ b/tests/units/cli/nrfu/test_commands.py @@ -1,81 +1,68 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.nrfu.commands -""" +"""Tests for anta.cli.nrfu.commands.""" + from __future__ import annotations import json import re from pathlib import Path - -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode +if TYPE_CHECKING: + from click.testing import CliRunner + DATA_DIR: Path = Path(__file__).parent.parent.parent.parent.resolve() / "data" def test_anta_nrfu_table_help(click_runner: CliRunner) -> None: - """ - Test anta nrfu table --help - """ + """Test anta nrfu table --help.""" result = click_runner.invoke(anta, ["nrfu", "table", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta nrfu table" in result.output def test_anta_nrfu_text_help(click_runner: CliRunner) -> None: - """ - Test anta nrfu text --help - """ + """Test anta nrfu text --help.""" result = click_runner.invoke(anta, ["nrfu", "text", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta nrfu text" in result.output def test_anta_nrfu_json_help(click_runner: CliRunner) -> None: - """ - Test anta nrfu json --help - """ + """Test anta nrfu json --help.""" result = click_runner.invoke(anta, ["nrfu", "json", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta nrfu json" in result.output def test_anta_nrfu_template_help(click_runner: CliRunner) -> None: - """ - Test anta nrfu tpl-report --help - """ + """Test anta nrfu tpl-report --help.""" result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta nrfu tpl-report" in result.output def test_anta_nrfu_table(click_runner: CliRunner) -> None: - """ - Test anta nrfu, catalog is given via env - """ + """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "table"]) assert result.exit_code == ExitCode.OK assert "dummy │ VerifyEOSVersion │ success" in result.output def test_anta_nrfu_text(click_runner: CliRunner) -> None: - """ - Test anta nrfu, catalog is given via env - """ + """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "text"]) assert result.exit_code == ExitCode.OK assert "dummy :: VerifyEOSVersion :: SUCCESS" in result.output def test_anta_nrfu_json(click_runner: CliRunner) -> None: - """ - Test anta nrfu, catalog is given via env - """ + """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "json"]) assert result.exit_code == ExitCode.OK assert "JSON results of all tests" in result.output @@ -89,9 +76,7 @@ def test_anta_nrfu_json(click_runner: CliRunner) -> None: def test_anta_nrfu_template(click_runner: CliRunner) -> None: - """ - Test anta nrfu, catalog is given via env - """ + """Test anta nrfu, catalog is given via env.""" result = click_runner.invoke(anta, ["nrfu", "tpl-report", "--template", str(DATA_DIR / "template.j2")]) assert result.exit_code == ExitCode.OK assert "* VerifyEOSVersion is SUCCESS for dummy" in result.output diff --git a/tests/units/cli/test__init__.py b/tests/units/cli/test__init__.py index 0e84e141d..3679e0dfd 100644 --- a/tests/units/cli/test__init__.py +++ b/tests/units/cli/test__init__.py @@ -1,58 +1,49 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.cli.__init__ -""" +"""Tests for anta.cli.__init__.""" from __future__ import annotations -from click.testing import CliRunner +from typing import TYPE_CHECKING from anta.cli import anta from anta.cli.utils import ExitCode +if TYPE_CHECKING: + from click.testing import CliRunner + def test_anta(click_runner: CliRunner) -> None: - """ - Test anta main entrypoint - """ + """Test anta main entrypoint.""" result = click_runner.invoke(anta) assert result.exit_code == ExitCode.OK assert "Usage" in result.output def test_anta_help(click_runner: CliRunner) -> None: - """ - Test anta --help - """ + """Test anta --help.""" result = click_runner.invoke(anta, ["--help"]) assert result.exit_code == ExitCode.OK assert "Usage" in result.output def test_anta_exec_help(click_runner: CliRunner) -> None: - """ - Test anta exec --help - """ + """Test anta exec --help.""" result = click_runner.invoke(anta, ["exec", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta exec" in result.output def test_anta_debug_help(click_runner: CliRunner) -> None: - """ - Test anta debug --help - """ + """Test anta debug --help.""" result = click_runner.invoke(anta, ["debug", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta debug" in result.output def test_anta_get_help(click_runner: CliRunner) -> None: - """ - Test anta get --help - """ + """Test anta get --help.""" result = click_runner.invoke(anta, ["get", "--help"]) assert result.exit_code == ExitCode.OK assert "Usage: anta get" in result.output diff --git a/tests/units/inventory/__init__.py b/tests/units/inventory/__init__.py index e772bee41..70fbdda9d 100644 --- a/tests/units/inventory/__init__.py +++ b/tests/units/inventory/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Tests for inventory submodule.""" diff --git a/tests/units/inventory/test_inventory.py b/tests/units/inventory/test_inventory.py index 7c62b5c53..430ca21cd 100644 --- a/tests/units/inventory/test_inventory.py +++ b/tests/units/inventory/test_inventory.py @@ -2,23 +2,25 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ANTA Inventory unit tests.""" + from __future__ import annotations -import logging -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pytest import yaml from pydantic import ValidationError from anta.inventory import AntaInventory -from anta.inventory.exceptions import InventoryIncorrectSchema, InventoryRootKeyError +from anta.inventory.exceptions import InventoryIncorrectSchemaError, InventoryRootKeyError from tests.data.json_data import ANTA_INVENTORY_TESTS_INVALID, ANTA_INVENTORY_TESTS_VALID from tests.lib.utils import generate_test_ids_dict +if TYPE_CHECKING: + from pathlib import Path + -class Test_AntaInventory: +class TestAntaInventory: """Test AntaInventory class.""" def create_inventory(self, content: str, tmp_path: Path) -> str: @@ -31,7 +33,7 @@ def create_inventory(self, content: str, tmp_path: Path) -> str: def check_parameter(self, parameter: str, test_definition: dict[Any, Any]) -> bool: """Check if parameter is configured in testbed.""" - return "parameters" in test_definition and parameter in test_definition["parameters"].keys() + return "parameters" in test_definition and parameter in test_definition["parameters"] @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_VALID, ids=generate_test_ids_dict) def test_init_valid(self, test_definition: dict[str, Any], tmp_path: Path) -> None: @@ -55,8 +57,7 @@ def test_init_valid(self, test_definition: dict[str, Any], tmp_path: Path) -> No try: AntaInventory.parse(filename=inventory_file, username="arista", password="arista123") except ValidationError as exc: - logging.error("Exceptions is: %s", str(exc)) - assert False + raise AssertionError from exc @pytest.mark.parametrize("test_definition", ANTA_INVENTORY_TESTS_INVALID, ids=generate_test_ids_dict) def test_init_invalid(self, test_definition: dict[str, Any], tmp_path: Path) -> None: @@ -77,5 +78,5 @@ def test_init_invalid(self, test_definition: dict[str, Any], tmp_path: Path) -> """ inventory_file = self.create_inventory(content=test_definition["input"], tmp_path=tmp_path) - with pytest.raises((InventoryIncorrectSchema, InventoryRootKeyError, ValidationError)): + with pytest.raises((InventoryIncorrectSchemaError, InventoryRootKeyError, ValidationError)): AntaInventory.parse(filename=inventory_file, username="arista", password="arista123") diff --git a/tests/units/inventory/test_models.py b/tests/units/inventory/test_models.py index 83f151c9f..0dccfb830 100644 --- a/tests/units/inventory/test_models.py +++ b/tests/units/inventory/test_models.py @@ -2,6 +2,7 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ANTA Inventory models unit tests.""" + from __future__ import annotations import logging @@ -30,7 +31,7 @@ from tests.lib.utils import generate_test_ids_dict -class Test_InventoryUnitModels: +class TestInventoryUnitModels: """Test components of AntaInventoryInput model.""" @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_VALID, ids=generate_test_ids_dict) @@ -51,9 +52,8 @@ def test_anta_inventory_host_valid(self, test_definition: dict[str, Any]) -> Non host_inventory = AntaInventoryHost(host=test_definition["input"]) except ValidationError as exc: logging.warning("Error: %s", str(exc)) - assert False - else: - assert test_definition["input"] == str(host_inventory.host) + raise AssertionError from exc + assert test_definition["input"] == str(host_inventory.host) @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_HOST_INVALID, ids=generate_test_ids_dict) def test_anta_inventory_host_invalid(self, test_definition: dict[str, Any]) -> None: @@ -110,9 +110,8 @@ def test_anta_inventory_network_valid(self, test_definition: dict[str, Any]) -> network_inventory = AntaInventoryNetwork(network=test_definition["input"]) except ValidationError as exc: logging.warning("Error: %s", str(exc)) - assert False - else: - assert test_definition["input"] == str(network_inventory.network) + raise AssertionError from exc + assert test_definition["input"] == str(network_inventory.network) @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_INVALID, ids=generate_test_ids_dict) def test_anta_inventory_network_invalid(self, test_definition: dict[str, Any]) -> None: @@ -133,11 +132,11 @@ def test_anta_inventory_network_invalid(self, test_definition: dict[str, Any]) - except ValidationError as exc: logging.warning("Error: %s", str(exc)) else: - assert False + raise AssertionError @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_NETWORK_CACHE, ids=generate_test_ids_dict) def test_anta_inventory_network_cache(self, test_definition: dict[str, Any]) -> None: - """Test network disable_cache + """Test network disable_cache. Test structure: --------------- @@ -176,10 +175,9 @@ def test_anta_inventory_range_valid(self, test_definition: dict[str, Any]) -> No ) except ValidationError as exc: logging.warning("Error: %s", str(exc)) - assert False - else: - assert test_definition["input"]["start"] == str(range_inventory.start) - assert test_definition["input"]["end"] == str(range_inventory.end) + raise AssertionError from exc + assert test_definition["input"]["start"] == str(range_inventory.start) + assert test_definition["input"]["end"] == str(range_inventory.end) @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_INVALID, ids=generate_test_ids_dict) def test_anta_inventory_range_invalid(self, test_definition: dict[str, Any]) -> None: @@ -203,11 +201,11 @@ def test_anta_inventory_range_invalid(self, test_definition: dict[str, Any]) -> except ValidationError as exc: logging.warning("Error: %s", str(exc)) else: - assert False + raise AssertionError @pytest.mark.parametrize("test_definition", INVENTORY_MODEL_RANGE_CACHE, ids=generate_test_ids_dict) def test_anta_inventory_range_cache(self, test_definition: dict[str, Any]) -> None: - """Test range disable_cache + """Test range disable_cache. Test structure: --------------- @@ -221,22 +219,23 @@ def test_anta_inventory_range_cache(self, test_definition: dict[str, Any]) -> No """ if "disable_cache" in test_definition["input"]: range_inventory = AntaInventoryRange( - start=test_definition["input"]["start"], end=test_definition["input"]["end"], disable_cache=test_definition["input"]["disable_cache"] + start=test_definition["input"]["start"], + end=test_definition["input"]["end"], + disable_cache=test_definition["input"]["disable_cache"], ) else: range_inventory = AntaInventoryRange(start=test_definition["input"]["start"], end=test_definition["input"]["end"]) assert test_definition["expected_result"] == range_inventory.disable_cache -class Test_AntaInventoryInputModel: +class TestAntaInventoryInputModel: """Unit test of AntaInventoryInput model.""" def test_inventory_input_structure(self) -> None: """Test inventory keys are those expected.""" - inventory = AntaInventoryInput() logging.info("Inventory keys are: %s", str(inventory.model_dump().keys())) - assert all(elem in inventory.model_dump().keys() for elem in ["hosts", "networks", "ranges"]) + assert all(elem in inventory.model_dump() for elem in ["hosts", "networks", "ranges"]) @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_VALID, ids=generate_test_ids_dict) def test_anta_inventory_intput_valid(self, inventory_def: dict[str, Any]) -> None: @@ -265,10 +264,9 @@ def test_anta_inventory_intput_valid(self, inventory_def: dict[str, Any]) -> Non inventory = AntaInventoryInput(**inventory_def["input"]) except ValidationError as exc: logging.warning("Error: %s", str(exc)) - assert False - else: - logging.info("Checking if all root keys are correctly lodaded") - assert all(elem in inventory.model_dump().keys() for elem in inventory_def["input"].keys()) + raise AssertionError from exc + logging.info("Checking if all root keys are correctly lodaded") + assert all(elem in inventory.model_dump() for elem in inventory_def["input"]) @pytest.mark.parametrize("inventory_def", INVENTORY_MODEL_INVALID, ids=generate_test_ids_dict) def test_anta_inventory_intput_invalid(self, inventory_def: dict[str, Any]) -> None: @@ -294,19 +292,19 @@ def test_anta_inventory_intput_invalid(self, inventory_def: dict[str, Any]) -> N """ try: - if "hosts" in inventory_def["input"].keys(): + if "hosts" in inventory_def["input"]: logging.info( "Loading %s into AntaInventoryInput hosts section", str(inventory_def["input"]["hosts"]), ) AntaInventoryInput(hosts=inventory_def["input"]["hosts"]) - if "networks" in inventory_def["input"].keys(): + if "networks" in inventory_def["input"]: logging.info( "Loading %s into AntaInventoryInput networks section", str(inventory_def["input"]["networks"]), ) AntaInventoryInput(networks=inventory_def["input"]["networks"]) - if "ranges" in inventory_def["input"].keys(): + if "ranges" in inventory_def["input"]: logging.info( "Loading %s into AntaInventoryInput ranges section", str(inventory_def["input"]["ranges"]), @@ -315,10 +313,10 @@ def test_anta_inventory_intput_invalid(self, inventory_def: dict[str, Any]) -> N except ValidationError as exc: logging.warning("Error: %s", str(exc)) else: - assert False + raise AssertionError -class Test_InventoryDeviceModel: +class TestInventoryDeviceModel: """Unit test of InventoryDevice model.""" @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_VALID, ids=generate_test_ids_dict) @@ -349,12 +347,12 @@ def test_inventory_device_valid(self, test_definition: dict[str, Any]) -> None: if test_definition["expected_result"] == "invalid": pytest.skip("Not concerned by the test") - for entity in test_definition["input"]: - try: + try: + for entity in test_definition["input"]: AsyncEOSDevice(**entity) - except TypeError as exc: - logging.warning("Error: %s", str(exc)) - assert False + except TypeError as exc: + logging.warning("Error: %s", str(exc)) + raise AssertionError from exc @pytest.mark.parametrize("test_definition", INVENTORY_DEVICE_MODEL_INVALID, ids=generate_test_ids_dict) def test_inventory_device_invalid(self, test_definition: dict[str, Any]) -> None: @@ -384,10 +382,10 @@ def test_inventory_device_invalid(self, test_definition: dict[str, Any]) -> None if test_definition["expected_result"] == "valid": pytest.skip("Not concerned by the test") - for entity in test_definition["input"]: - try: + try: + for entity in test_definition["input"]: AsyncEOSDevice(**entity) - except TypeError as exc: - logging.info("Error: %s", str(exc)) - else: - assert False + except TypeError as exc: + logging.info("Error: %s", str(exc)) + else: + raise AssertionError diff --git a/tests/units/reporter/__init__.py b/tests/units/reporter/__init__.py index e772bee41..6e606e564 100644 --- a/tests/units/reporter/__init__.py +++ b/tests/units/reporter/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Tests for anta.reporter submodule.""" diff --git a/tests/units/reporter/test__init__.py b/tests/units/reporter/test__init__.py index 259942f9c..bf23c918c 100644 --- a/tests/units/reporter/test__init__.py +++ b/tests/units/reporter/test__init__.py @@ -1,31 +1,30 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test anta.report.__init__.py -""" +"""Test anta.report.__init__.py.""" + from __future__ import annotations -from typing import Callable +from typing import TYPE_CHECKING, Callable import pytest from rich.table import Table from anta import RICH_COLOR_PALETTE -from anta.custom_types import TestStatus from anta.reporter import ReportTable -from anta.result_manager import ResultManager + +if TYPE_CHECKING: + from anta.custom_types import TestStatus + from anta.result_manager import ResultManager -class Test_ReportTable: - """ - Test ReportTable class - """ +class TestReportTable: + """Test ReportTable class.""" # not testing __init__ as nothing is going on there @pytest.mark.parametrize( - "usr_list, delimiter, expected_output", + ("usr_list", "delimiter", "expected_output"), [ pytest.param([], None, "", id="empty list no delimiter"), pytest.param([], "*", "", id="empty list with delimiter"), @@ -36,9 +35,7 @@ class Test_ReportTable: ], ) def test__split_list_to_txt_list(self, usr_list: list[str], delimiter: str | None, expected_output: str) -> None: - """ - test _split_list_to_txt_list - """ + """Test _split_list_to_txt_list.""" # pylint: disable=protected-access report = ReportTable() assert report._split_list_to_txt_list(usr_list, delimiter) == expected_output @@ -52,9 +49,7 @@ def test__split_list_to_txt_list(self, usr_list: list[str], delimiter: str | Non ], ) def test__build_headers(self, headers: list[str]) -> None: - """ - test _build_headers - """ + """Test _build_headers.""" # pylint: disable=protected-access report = ReportTable() table = Table() @@ -65,7 +60,7 @@ def test__build_headers(self, headers: list[str]) -> None: assert table.columns[table_column_before].style == RICH_COLOR_PALETTE.HEADER @pytest.mark.parametrize( - "status, expected_status", + ("status", "expected_status"), [ pytest.param("unknown", "unknown", id="unknown status"), pytest.param("unset", "[grey74]unset", id="unset status"), @@ -76,15 +71,13 @@ def test__build_headers(self, headers: list[str]) -> None: ], ) def test__color_result(self, status: TestStatus, expected_status: str) -> None: - """ - test _build_headers - """ + """Test _build_headers.""" # pylint: disable=protected-access report = ReportTable() assert report._color_result(status) == expected_status @pytest.mark.parametrize( - "host, testcase, title, number_of_tests, expected_length", + ("host", "testcase", "title", "number_of_tests", "expected_length"), [ pytest.param(None, None, None, 5, 5, id="all results"), pytest.param("host1", None, None, 5, 0, id="result for host1 when no host1 test"), @@ -101,9 +94,7 @@ def test_report_all( number_of_tests: int, expected_length: int, ) -> None: - """ - test report_all - """ + """Test report_all.""" # pylint: disable=too-many-arguments rm = result_manager_factory(number_of_tests) @@ -117,7 +108,7 @@ def test_report_all( assert res.row_count == expected_length @pytest.mark.parametrize( - "testcase, title, number_of_tests, expected_length", + ("testcase", "title", "number_of_tests", "expected_length"), [ pytest.param(None, None, 5, 5, id="all results"), pytest.param("VerifyTest3", None, 5, 1, id="result for test VerifyTest3"), @@ -132,11 +123,9 @@ def test_report_summary_tests( number_of_tests: int, expected_length: int, ) -> None: - """ - test report_summary_tests - """ + """Test report_summary_tests.""" # pylint: disable=too-many-arguments - # TODO refactor this later... this is injecting double test results by modyfing the device name + # TODO: refactor this later... this is injecting double test results by modyfing the device name # should be a fixture rm = result_manager_factory(number_of_tests) new_results = [result.model_copy() for result in rm.get_results()] @@ -155,7 +144,7 @@ def test_report_summary_tests( assert res.row_count == expected_length @pytest.mark.parametrize( - "host, title, number_of_tests, expected_length", + ("host", "title", "number_of_tests", "expected_length"), [ pytest.param(None, None, 5, 2, id="all results"), pytest.param("host1", None, 5, 1, id="result for host host1"), @@ -170,11 +159,9 @@ def test_report_summary_hosts( number_of_tests: int, expected_length: int, ) -> None: - """ - test report_summary_hosts - """ + """Test report_summary_hosts.""" # pylint: disable=too-many-arguments - # TODO refactor this later... this is injecting double test results by modyfing the device name + # TODO: refactor this later... this is injecting double test results by modyfing the device name # should be a fixture rm = result_manager_factory(number_of_tests) new_results = [result.model_copy() for result in rm.get_results()] diff --git a/tests/units/result_manager/__init__.py b/tests/units/result_manager/__init__.py index e772bee41..861145b7f 100644 --- a/tests/units/result_manager/__init__.py +++ b/tests/units/result_manager/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Tests for anta.result_manager submodule.""" diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py index c457c8465..9c75e1a4a 100644 --- a/tests/units/result_manager/test__init__.py +++ b/tests/units/result_manager/test__init__.py @@ -1,35 +1,30 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Test anta.result_manager.__init__.py -""" +"""Test anta.result_manager.__init__.py.""" + from __future__ import annotations import json -from contextlib import nullcontext -from typing import TYPE_CHECKING, Any, Callable +from contextlib import AbstractContextManager, nullcontext +from typing import TYPE_CHECKING, Callable import pytest -from anta.custom_types import TestStatus from anta.result_manager import ResultManager if TYPE_CHECKING: + from anta.custom_types import TestStatus from anta.result_manager.models import TestResult -class Test_ResultManager: - """ - Test ResultManager class - """ +class TestResultManager: + """Test ResultManager class.""" # not testing __init__ as nothing is going on there def test__len__(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: - """ - test __len__ - """ + """Test __len__.""" list_result = list_result_factory(3) result_manager = ResultManager() assert len(result_manager) == 0 @@ -38,30 +33,66 @@ def test__len__(self, list_result_factory: Callable[[int], list[TestResult]]) -> assert len(result_manager) == i + 1 @pytest.mark.parametrize( - "starting_status, test_status, expected_status, expected_raise", + ("starting_status", "test_status", "expected_status", "expected_raise"), [ pytest.param("unset", "unset", "unset", nullcontext(), id="unset->unset"), pytest.param("unset", "success", "success", nullcontext(), id="unset->success"), pytest.param("unset", "error", "unset", nullcontext(), id="set error"), pytest.param("skipped", "skipped", "skipped", nullcontext(), id="skipped->skipped"), pytest.param("skipped", "unset", "skipped", nullcontext(), id="skipped, add unset"), - pytest.param("skipped", "success", "success", nullcontext(), id="skipped, add success"), - pytest.param("skipped", "failure", "failure", nullcontext(), id="skipped, add failure"), + pytest.param( + "skipped", + "success", + "success", + nullcontext(), + id="skipped, add success", + ), + pytest.param( + "skipped", + "failure", + "failure", + nullcontext(), + id="skipped, add failure", + ), pytest.param("success", "unset", "success", nullcontext(), id="success, add unset"), - pytest.param("success", "skipped", "success", nullcontext(), id="success, add skipped"), + pytest.param( + "success", + "skipped", + "success", + nullcontext(), + id="success, add skipped", + ), pytest.param("success", "success", "success", nullcontext(), id="success->success"), pytest.param("success", "failure", "failure", nullcontext(), id="success->failure"), pytest.param("failure", "unset", "failure", nullcontext(), id="failure->failure"), pytest.param("failure", "skipped", "failure", nullcontext(), id="failure, add unset"), - pytest.param("failure", "success", "failure", nullcontext(), id="failure, add skipped"), - pytest.param("failure", "failure", "failure", nullcontext(), id="failure, add success"), - pytest.param("unset", "unknown", None, pytest.raises(ValueError), id="wrong status"), + pytest.param( + "failure", + "success", + "failure", + nullcontext(), + id="failure, add skipped", + ), + pytest.param( + "failure", + "failure", + "failure", + nullcontext(), + id="failure, add success", + ), + pytest.param( + "unset", "unknown", None, pytest.raises(ValueError, match="Input should be 'unset', 'success', 'failure', 'error' or 'skipped'"), id="wrong status" + ), ], ) - def test__update_status(self, starting_status: TestStatus, test_status: TestStatus, expected_status: str, expected_raise: Any) -> None: - """ - Test ResultManager._update_status - """ + def test__update_status( + self, + starting_status: TestStatus, + test_status: TestStatus, + expected_status: str, + expected_raise: AbstractContextManager[Exception], + ) -> None: + """Test ResultManager._update_status.""" result_manager = ResultManager() result_manager.status = starting_status assert result_manager.error_status is False @@ -74,9 +105,7 @@ def test__update_status(self, starting_status: TestStatus, test_status: TestStat assert result_manager.status == expected_status def test_add_test_result(self, test_result_factory: Callable[[int], TestResult]) -> None: - """ - Test ResultManager.add_test_result - """ + """Test ResultManager.add_test_result.""" result_manager = ResultManager() assert result_manager.status == "unset" assert result_manager.error_status is False @@ -115,9 +144,7 @@ def test_add_test_result(self, test_result_factory: Callable[[int], TestResult]) assert len(result_manager) == 4 def test_add_test_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: - """ - Test ResultManager.add_test_results - """ + """Test ResultManager.add_test_results.""" result_manager = ResultManager() assert result_manager.status == "unset" assert result_manager.error_status is False @@ -142,17 +169,21 @@ def test_add_test_results(self, list_result_factory: Callable[[int], list[TestRe assert len(result_manager) == 5 @pytest.mark.parametrize( - "status, error_status, ignore_error, expected_status", + ("status", "error_status", "ignore_error", "expected_status"), [ pytest.param("success", False, True, "success", id="no error"), pytest.param("success", True, True, "success", id="error, ignore error"), pytest.param("success", True, False, "error", id="error, do not ignore error"), ], ) - def test_get_status(self, status: TestStatus, error_status: bool, ignore_error: bool, expected_status: str) -> None: - """ - test ResultManager.get_status - """ + def test_get_status( + self, + status: TestStatus, + error_status: bool, + ignore_error: bool, + expected_status: str, + ) -> None: + """Test ResultManager.get_status.""" result_manager = ResultManager() result_manager.status = status result_manager.error_status = error_status @@ -160,9 +191,7 @@ def test_get_status(self, status: TestStatus, error_status: bool, ignore_error: assert result_manager.get_status(ignore_error=ignore_error) == expected_status def test_get_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: - """ - test ResultManager.get_results - """ + """Test ResultManager.get_results.""" result_manager = ResultManager() success_list = list_result_factory(3) @@ -174,9 +203,7 @@ def test_get_results(self, list_result_factory: Callable[[int], list[TestResult] assert isinstance(res, list) def test_get_json_results(self, list_result_factory: Callable[[int], list[TestResult]]) -> None: - """ - test ResultManager.get_json_results - """ + """Test ResultManager.get_json_results.""" result_manager = ResultManager() success_list = list_result_factory(3) @@ -197,7 +224,7 @@ def test_get_json_results(self, list_result_factory: Callable[[int], list[TestRe assert test.get("custom_field") is None assert test.get("result") == "success" - # TODO + # TODO: implement missing functions # get_result_by_test # get_result_by_host # get_testcases diff --git a/tests/units/result_manager/test_models.py b/tests/units/result_manager/test_models.py index bc7ba8ac6..2276153f8 100644 --- a/tests/units/result_manager/test_models.py +++ b/tests/units/result_manager/test_models.py @@ -2,18 +2,21 @@ # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. """ANTA Result Manager models unit tests.""" + from __future__ import annotations -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable import pytest # Import as Result to avoid pytest collection -from anta.result_manager.models import TestResult as Result from tests.data.json_data import TEST_RESULT_SET_STATUS from tests.lib.fixture import DEVICE_NAME from tests.lib.utils import generate_test_ids_dict +if TYPE_CHECKING: + from anta.result_manager.models import TestResult as Result + class TestTestResultModels: """Test components of anta.result_manager.models.""" diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py index 0b3045aa7..d0def3317 100644 --- a/tests/units/test_catalog.py +++ b/tests/units/test_catalog.py @@ -1,9 +1,8 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -test anta.device.py -""" +"""test anta.device.py.""" + from __future__ import annotations from pathlib import Path @@ -169,64 +168,52 @@ ] -class Test_AntaCatalog: - """ - Test for anta.catalog.AntaCatalog - """ +class TestAntaCatalog: + """Test for anta.catalog.AntaCatalog.""" @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) def test_parse(self, catalog_data: dict[str, Any]) -> None: - """ - Instantiate AntaCatalog from a file - """ + """Instantiate AntaCatalog from a file.""" catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"])) assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): assert catalog.tests[test_id].test == test - if inputs is not None: - if isinstance(inputs, dict): - inputs = test.Input(**inputs) + if inputs_data is not None: + inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) def test_from_list(self, catalog_data: dict[str, Any]) -> None: - """ - Instantiate AntaCatalog from a list - """ + """Instantiate AntaCatalog from a list.""" catalog: AntaCatalog = AntaCatalog.from_list(catalog_data["tests"]) assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): assert catalog.tests[test_id].test == test - if inputs is not None: - if isinstance(inputs, dict): - inputs = test.Input(**inputs) + if inputs_data is not None: + inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) def test_from_dict(self, catalog_data: dict[str, Any]) -> None: - """ - Instantiate AntaCatalog from a dict - """ - with open(file=str(DATA_DIR / catalog_data["filename"]), mode="r", encoding="UTF-8") as file: - data = safe_load(file) + """Instantiate AntaCatalog from a dict.""" + file = DATA_DIR / catalog_data["filename"] + with file.open(encoding="UTF-8") as f: + data = safe_load(f) catalog: AntaCatalog = AntaCatalog.from_dict(data) assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): assert catalog.tests[test_id].test == test - if inputs is not None: - if isinstance(inputs, dict): - inputs = test.Input(**inputs) + if inputs_data is not None: + inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs @pytest.mark.parametrize("catalog_data", CATALOG_PARSE_FAIL_DATA, ids=generate_test_ids_list(CATALOG_PARSE_FAIL_DATA)) def test_parse_fail(self, catalog_data: dict[str, Any]) -> None: - """ - Errors when instantiating AntaCatalog from a file - """ - with pytest.raises((ValidationError, ValueError)) as exec_info: + """Errors when instantiating AntaCatalog from a file.""" + with pytest.raises((ValidationError, TypeError)) as exec_info: AntaCatalog.parse(str(DATA_DIR / catalog_data["filename"])) if isinstance(exec_info.value, ValidationError): assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] @@ -234,10 +221,8 @@ def test_parse_fail(self, catalog_data: dict[str, Any]) -> None: assert catalog_data["error"] in str(exec_info) def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None: - """ - Errors when instantiating AntaCatalog from a file - """ - with pytest.raises(Exception) as exec_info: + """Errors when instantiating AntaCatalog from a file.""" + with pytest.raises(FileNotFoundError) as exec_info: AntaCatalog.parse(str(DATA_DIR / "catalog_does_not_exist.yml")) assert "No such file or directory" in str(exec_info) assert len(caplog.record_tuples) >= 1 @@ -247,21 +232,18 @@ def test_parse_fail_parsing(self, caplog: pytest.LogCaptureFixture) -> None: @pytest.mark.parametrize("catalog_data", CATALOG_FROM_LIST_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_LIST_FAIL_DATA)) def test_from_list_fail(self, catalog_data: dict[str, Any]) -> None: - """ - Errors when instantiating AntaCatalog from a list of tuples - """ + """Errors when instantiating AntaCatalog from a list of tuples.""" with pytest.raises(ValidationError) as exec_info: AntaCatalog.from_list(catalog_data["tests"]) assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] @pytest.mark.parametrize("catalog_data", CATALOG_FROM_DICT_FAIL_DATA, ids=generate_test_ids_list(CATALOG_FROM_DICT_FAIL_DATA)) def test_from_dict_fail(self, catalog_data: dict[str, Any]) -> None: - """ - Errors when instantiating AntaCatalog from a list of tuples - """ - with open(file=str(DATA_DIR / catalog_data["filename"]), mode="r", encoding="UTF-8") as file: - data = safe_load(file) - with pytest.raises((ValidationError, ValueError)) as exec_info: + """Errors when instantiating AntaCatalog from a list of tuples.""" + file = DATA_DIR / catalog_data["filename"] + with file.open(encoding="UTF-8") as f: + data = safe_load(f) + with pytest.raises((ValidationError, TypeError)) as exec_info: AntaCatalog.from_dict(data) if isinstance(exec_info.value, ValidationError): assert catalog_data["error"] in exec_info.value.errors()[0]["msg"] @@ -269,9 +251,7 @@ def test_from_dict_fail(self, catalog_data: dict[str, Any]) -> None: assert catalog_data["error"] in str(exec_info) def test_filename(self) -> None: - """ - Test filename - """ + """Test filename.""" catalog = AntaCatalog(filename="test") assert catalog.filename == Path("test") catalog = AntaCatalog(filename=Path("test")) @@ -279,33 +259,26 @@ def test_filename(self) -> None: @pytest.mark.parametrize("catalog_data", INIT_CATALOG_DATA, ids=generate_test_ids_list(INIT_CATALOG_DATA)) def test__tests_setter_success(self, catalog_data: dict[str, Any]) -> None: - """ - Success when setting AntaCatalog.tests from a list of tuples - """ + """Success when setting AntaCatalog.tests from a list of tuples.""" catalog = AntaCatalog() catalog.tests = [AntaTestDefinition(test=test, inputs=inputs) for test, inputs in catalog_data["tests"]] assert len(catalog.tests) == len(catalog_data["tests"]) - for test_id, (test, inputs) in enumerate(catalog_data["tests"]): + for test_id, (test, inputs_data) in enumerate(catalog_data["tests"]): assert catalog.tests[test_id].test == test - if inputs is not None: - if isinstance(inputs, dict): - inputs = test.Input(**inputs) + if inputs_data is not None: + inputs = test.Input(**inputs_data) if isinstance(inputs_data, dict) else inputs_data assert inputs == catalog.tests[test_id].inputs @pytest.mark.parametrize("catalog_data", TESTS_SETTER_FAIL_DATA, ids=generate_test_ids_list(TESTS_SETTER_FAIL_DATA)) def test__tests_setter_fail(self, catalog_data: dict[str, Any]) -> None: - """ - Errors when setting AntaCatalog.tests from a list of tuples - """ + """Errors when setting AntaCatalog.tests from a list of tuples.""" catalog = AntaCatalog() - with pytest.raises(ValueError) as exec_info: + with pytest.raises(TypeError) as exec_info: catalog.tests = catalog_data["tests"] assert catalog_data["error"] in str(exec_info) def test_get_tests_by_tags(self) -> None: - """ - Test AntaCatalog.test_get_tests_by_tags() - """ + """Test AntaCatalog.get_tests_by_tags().""" catalog: AntaCatalog = AntaCatalog.parse(str(DATA_DIR / "test_catalog_with_tags.yml")) tests: list[AntaTestDefinition] = catalog.get_tests_by_tags(tags=["leaf"]) assert len(tests) == 2 diff --git a/tests/units/test_device.py b/tests/units/test_device.py index 845da2bc0..6d19df45f 100644 --- a/tests/units/test_device.py +++ b/tests/units/test_device.py @@ -1,20 +1,17 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -test anta.device.py -""" +"""test anta.device.py.""" from __future__ import annotations import asyncio from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import patch import httpx import pytest -from _pytest.mark.structures import ParameterSet from asyncssh import SSHClientConnection, SSHClientConnectionOptions from rich import print as rprint @@ -24,6 +21,9 @@ from tests.lib.fixture import COMMAND_OUTPUT from tests.lib.utils import generate_test_ids_list +if TYPE_CHECKING: + from _pytest.mark.structures import ParameterSet + INIT_DATA: list[dict[str, Any]] = [ { "name": "no name, no port", @@ -155,8 +155,8 @@ "memTotal": 8099732, "memFree": 4989568, "isIntlVersion": False, - } - ] + }, + ], }, }, "expected": { @@ -211,7 +211,7 @@ "memFree": 4989568, "isIntlVersion": False, }, - ] + ], }, }, "expected": { @@ -266,7 +266,7 @@ "memFree": 4989568, "isIntlVersion": False, }, - ] + ], }, }, "expected": { @@ -322,7 +322,7 @@ "memFree": 4989568, "isIntlVersion": False, }, - ] + ], }, }, "expected": { @@ -356,8 +356,12 @@ "command": "show version", "patch_kwargs": { "side_effect": aioeapi.EapiCommandError( - passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[] - ) + passed=[], + failed="show version", + errors=["Authorization denied for command 'show version'"], + errmsg="Invalid command", + not_exec=[], + ), }, }, "expected": {"output": None, "errors": ["Authorization denied for command 'show version'"]}, @@ -387,7 +391,7 @@ "device": {}, "copy": { "sources": [Path("/mnt/flash"), Path("/var/log/agents")], - "destination": Path("."), + "destination": Path(), "direction": "from", }, }, @@ -396,7 +400,7 @@ "device": {}, "copy": { "sources": [Path("/mnt/flash"), Path("/var/log/agents")], - "destination": Path("."), + "destination": Path(), "direction": "to", }, }, @@ -405,7 +409,7 @@ "device": {}, "copy": { "sources": [Path("/mnt/flash"), Path("/var/log/agents")], - "destination": Path("."), + "destination": Path(), "direction": "wrong", }, }, @@ -436,7 +440,7 @@ "memTotal": 8099732, "memFree": 4989568, "isIntlVersion": False, - } + }, }, ), "expected": {"is_online": True, "established": True, "hw_model": "DCS-7280CR3-32P4-F"}, @@ -466,7 +470,7 @@ "memTotal": 8099732, "memFree": 4989568, "isIntlVersion": False, - } + }, }, ), "expected": {"is_online": False, "established": False, "hw_model": None}, @@ -495,7 +499,7 @@ "memTotal": 8099732, "memFree": 4989568, "isIntlVersion": False, - } + }, }, ), "expected": {"is_online": True, "established": False, "hw_model": None}, @@ -507,8 +511,12 @@ {"return_value": True}, { "side_effect": aioeapi.EapiCommandError( - passed=[], failed="show version", errors=["Authorization denied for command 'show version'"], errmsg="Invalid command", not_exec=[] - ) + passed=[], + failed="show version", + errors=["Authorization denied for command 'show version'"], + errmsg="Invalid command", + not_exec=[], + ), }, ), "expected": {"is_online": True, "established": False, "hw_model": None}, @@ -599,21 +607,17 @@ class TestAntaDevice: - """ - Test for anta.device.AntaDevice Abstract class - """ + """Test for anta.device.AntaDevice Abstract class.""" - @pytest.mark.asyncio + @pytest.mark.asyncio() @pytest.mark.parametrize( - "device, command_data, expected_data", - map(lambda d: (d["device"], d["command"], d["expected"]), COLLECT_DATA), + ("device", "command_data", "expected_data"), + ((d["device"], d["command"], d["expected"]) for d in COLLECT_DATA), indirect=["device"], ids=generate_test_ids_list(COLLECT_DATA), ) async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], expected_data: dict[str, Any]) -> None: - """ - Test AntaDevice.collect behavior - """ + """Test AntaDevice.collect behavior.""" command = AntaCommand(command=command_data["command"], use_cache=command_data["use_cache"]) # Dummy output for cache hit @@ -646,18 +650,16 @@ async def test_collect(self, device: AntaDevice, command_data: dict[str, Any], e assert device.cache is None device._collect.assert_called_once_with(command=command) # type: ignore[attr-defined] # pylint: disable=protected-access - @pytest.mark.parametrize("device, expected", CACHE_STATS_DATA, indirect=["device"]) + @pytest.mark.parametrize(("device", "expected"), CACHE_STATS_DATA, indirect=["device"]) def test_cache_statistics(self, device: AntaDevice, expected: dict[str, Any] | None) -> None: - """ - Verify that when cache statistics attribute does not exist - TODO add a test where cache has some value + """Verify that when cache statistics attribute does not exist. + + TODO add a test where cache has some value. """ assert device.cache_statistics == expected def test_supports(self, device: AntaDevice) -> None: - """ - Test if the supports() method - """ + """Test if the supports() method.""" command = AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"]) assert device.supports(command) is False command = AntaCommand(command="show hardware counter drop") @@ -665,13 +667,11 @@ def test_supports(self, device: AntaDevice) -> None: class TestAsyncEOSDevice: - """ - Test for anta.device.AsyncEOSDevice - """ + """Test for anta.device.AsyncEOSDevice.""" @pytest.mark.parametrize("data", INIT_DATA, ids=generate_test_ids_list(INIT_DATA)) def test__init__(self, data: dict[str, Any]) -> None: - """Test the AsyncEOSDevice constructor""" + """Test the AsyncEOSDevice constructor.""" device = AsyncEOSDevice(**data["device"]) assert device.name == data["expected"]["name"] @@ -683,12 +683,12 @@ def test__init__(self, data: dict[str, Any]) -> None: assert device.cache_locks is not None hash(device) - with patch("anta.device.__DEBUG__", True): + with patch("anta.device.__DEBUG__", new=True): rprint(device) @pytest.mark.parametrize("data", EQUALITY_DATA, ids=generate_test_ids_list(EQUALITY_DATA)) def test__eq(self, data: dict[str, Any]) -> None: - """Test the AsyncEOSDevice equality""" + """Test the AsyncEOSDevice equality.""" device1 = AsyncEOSDevice(**data["device1"]) device2 = AsyncEOSDevice(**data["device2"]) if data["expected"]: @@ -696,49 +696,45 @@ def test__eq(self, data: dict[str, Any]) -> None: else: assert device1 != device2 - @pytest.mark.asyncio + @pytest.mark.asyncio() @pytest.mark.parametrize( - "async_device, patch_kwargs, expected", - map(lambda d: (d["device"], d["patch_kwargs"], d["expected"]), REFRESH_DATA), + ("async_device", "patch_kwargs", "expected"), + ((d["device"], d["patch_kwargs"], d["expected"]) for d in REFRESH_DATA), ids=generate_test_ids_list(REFRESH_DATA), indirect=["async_device"], ) async def test_refresh(self, async_device: AsyncEOSDevice, patch_kwargs: list[dict[str, Any]], expected: dict[str, Any]) -> None: # pylint: disable=protected-access - """Test AsyncEOSDevice.refresh()""" - with patch.object(async_device._session, "check_connection", **patch_kwargs[0]): - with patch.object(async_device._session, "cli", **patch_kwargs[1]): - await async_device.refresh() - async_device._session.check_connection.assert_called_once() - if expected["is_online"]: - async_device._session.cli.assert_called_once() - assert async_device.is_online == expected["is_online"] - assert async_device.established == expected["established"] - assert async_device.hw_model == expected["hw_model"] + """Test AsyncEOSDevice.refresh().""" + with patch.object(async_device._session, "check_connection", **patch_kwargs[0]), patch.object(async_device._session, "cli", **patch_kwargs[1]): + await async_device.refresh() + async_device._session.check_connection.assert_called_once() + if expected["is_online"]: + async_device._session.cli.assert_called_once() + assert async_device.is_online == expected["is_online"] + assert async_device.established == expected["established"] + assert async_device.hw_model == expected["hw_model"] - @pytest.mark.asyncio + @pytest.mark.asyncio() @pytest.mark.parametrize( - "async_device, command, expected", - map(lambda d: (d["device"], d["command"], d["expected"]), AIOEAPI_COLLECT_DATA), + ("async_device", "command", "expected"), + ((d["device"], d["command"], d["expected"]) for d in AIOEAPI_COLLECT_DATA), ids=generate_test_ids_list(AIOEAPI_COLLECT_DATA), indirect=["async_device"], ) async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, Any], expected: dict[str, Any]) -> None: # pylint: disable=protected-access - """Test AsyncEOSDevice._collect()""" - if "revision" in command: - cmd = AntaCommand(command=command["command"], revision=command["revision"]) - else: - cmd = AntaCommand(command=command["command"]) + """Test AsyncEOSDevice._collect().""" + cmd = AntaCommand(command=command["command"], revision=command["revision"]) if "revision" in command else AntaCommand(command=command["command"]) with patch.object(async_device._session, "cli", **command["patch_kwargs"]): await async_device.collect(cmd) - commands = [] + commands: list[dict[str, Any]] = [] if async_device.enable and async_device._enable_password is not None: commands.append( { "cmd": "enable", "input": str(async_device._enable_password), - } + }, ) elif async_device.enable: # No password @@ -751,15 +747,15 @@ async def test__collect(self, async_device: AsyncEOSDevice, command: dict[str, A assert cmd.output == expected["output"] assert cmd.errors == expected["errors"] - @pytest.mark.asyncio + @pytest.mark.asyncio() @pytest.mark.parametrize( - "async_device, copy", - map(lambda d: (d["device"], d["copy"]), AIOEAPI_COPY_DATA), + ("async_device", "copy"), + ((d["device"], d["copy"]) for d in AIOEAPI_COPY_DATA), ids=generate_test_ids_list(AIOEAPI_COPY_DATA), indirect=["async_device"], ) async def test_copy(self, async_device: AsyncEOSDevice, copy: dict[str, Any]) -> None: - """Test AsyncEOSDevice.copy()""" + """Test AsyncEOSDevice.copy().""" conn = SSHClientConnection(asyncio.get_event_loop(), SSHClientConnectionOptions()) with patch("asyncssh.connect") as connect_mock: connect_mock.return_value.__aenter__.return_value = conn diff --git a/tests/units/test_logger.py b/tests/units/test_logger.py index 72c60d5ca..11e63f119 100644 --- a/tests/units/test_logger.py +++ b/tests/units/test_logger.py @@ -1,28 +1,37 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.logger -""" +"""Tests for anta.logger.""" + from __future__ import annotations import logging -from typing import TYPE_CHECKING from unittest.mock import patch import pytest from anta.logger import anta_log_exception, exc_to_str, tb_to_str -if TYPE_CHECKING: - from pytest import LogCaptureFixture - @pytest.mark.parametrize( - "exception, message, calling_logger, __DEBUG__value, expected_message", + ("exception", "message", "calling_logger", "debug_value", "expected_message"), [ - pytest.param(ValueError("exception message"), None, None, False, "ValueError: exception message", id="exception only"), - pytest.param(ValueError("exception message"), "custom message", None, False, "custom message\nValueError: exception message", id="custom message"), + pytest.param( + ValueError("exception message"), + None, + None, + False, + "ValueError: exception message", + id="exception only", + ), + pytest.param( + ValueError("exception message"), + "custom message", + None, + False, + "custom message\nValueError: exception message", + id="custom message", + ), pytest.param( ValueError("exception message"), "custom logger", @@ -32,22 +41,24 @@ id="custom logger", ), pytest.param( - ValueError("exception message"), "Use with custom message", None, True, "Use with custom message\nValueError: exception message", id="__DEBUG__ on" + ValueError("exception message"), + "Use with custom message", + None, + True, + "Use with custom message\nValueError: exception message", + id="__DEBUG__ on", ), ], ) def test_anta_log_exception( - caplog: LogCaptureFixture, + caplog: pytest.LogCaptureFixture, exception: Exception, message: str | None, calling_logger: logging.Logger | None, - __DEBUG__value: bool, + debug_value: bool, expected_message: str, ) -> None: - """ - Test anta_log_exception - """ - + """Test anta_log_exception.""" if calling_logger is not None: # https://github.com/pytest-dev/pytest/issues/3697 calling_logger.propagate = True @@ -58,11 +69,11 @@ def test_anta_log_exception( try: raise exception except ValueError as e: - with patch("anta.logger.__DEBUG__", __DEBUG__value): + with patch("anta.logger.__DEBUG__", new=debug_value): anta_log_exception(e, message=message, calling_logger=calling_logger) # Two log captured - if __DEBUG__value: + if debug_value: assert len(caplog.record_tuples) == 2 else: assert len(caplog.record_tuples) == 1 @@ -76,29 +87,26 @@ def test_anta_log_exception( assert level == logging.CRITICAL assert message == expected_message # the only place where we can see the stracktrace is in the capture.text - if __DEBUG__value is True: + if debug_value: assert "Traceback" in caplog.text def my_raising_function(exception: Exception) -> None: - """ - dummy function to raise Exception - """ + """Raise Exception.""" raise exception -@pytest.mark.parametrize("exception, expected_output", [(ValueError("test"), "ValueError: test"), (ValueError(), "ValueError")]) +@pytest.mark.parametrize( + ("exception", "expected_output"), + [(ValueError("test"), "ValueError: test"), (ValueError(), "ValueError")], +) def test_exc_to_str(exception: Exception, expected_output: str) -> None: - """ - Test exc_to_str - """ + """Test exc_to_str.""" assert exc_to_str(exception) == expected_output def test_tb_to_str() -> None: - """ - Test tb_to_str - """ + """Test tb_to_str.""" try: my_raising_function(ValueError("test")) except ValueError as e: diff --git a/tests/units/test_models.py b/tests/units/test_models.py index c0585a491..17c6eaba1 100644 --- a/tests/units/test_models.py +++ b/tests/units/test_models.py @@ -1,209 +1,242 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -test anta.models.py -""" +"""test anta.models.py.""" + # Mypy does not understand AntaTest.Input typing # mypy: disable-error-code=attr-defined from __future__ import annotations import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any, ClassVar import pytest from anta.decorators import deprecated_test, skip_on_platforms -from anta.device import AntaDevice from anta.models import AntaCommand, AntaTemplate, AntaTest from tests.lib.fixture import DEVICE_HW_MODEL from tests.lib.utils import generate_test_ids +if TYPE_CHECKING: + from anta.device import AntaDevice + class FakeTest(AntaTest): - """ANTA test that always succeed""" + """ANTA test that always succeed.""" name = "FakeTest" description = "ANTA test that always succeed" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() class FakeTestWithFailedCommand(AntaTest): - """ANTA test with a command that failed""" + """ANTA test with a command that failed.""" name = "FakeTestWithFailedCommand" description = "ANTA test with a command that failed" - categories = [] - commands = [AntaCommand(command="show version", errors=["failed command"])] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show version", errors=["failed command"])] @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() class FakeTestWithUnsupportedCommand(AntaTest): - """ANTA test with an unsupported command""" + """ANTA test with an unsupported command.""" name = "FakeTestWithUnsupportedCommand" description = "ANTA test with an unsupported command" - categories = [] - commands = [AntaCommand(command="show hardware counter drop", errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"])] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [ + AntaCommand( + command="show hardware counter drop", + errors=["Unavailable command (not supported on this hardware platform) (at token 2: 'counter')"], + ) + ] @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() class FakeTestWithInput(AntaTest): - """ANTA test with inputs that always succeed""" + """ANTA test with inputs that always succeed.""" name = "FakeTestWithInput" description = "ANTA test with inputs that always succeed" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] + + class Input(AntaTest.Input): + """Inputs for FakeTestWithInput test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring string: str @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success(self.inputs.string) class FakeTestWithTemplate(AntaTest): - """ANTA test with template that always succeed""" + """ANTA test with template that always succeed.""" name = "FakeTestWithTemplate" description = "ANTA test with template that always succeed" - categories = [] - commands = [AntaTemplate(template="show interface {interface}")] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): + """Inputs for FakeTestWithTemplate test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring interface: str def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render function.""" return [template.render(interface=self.inputs.interface)] @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success(self.instance_commands[0].command) class FakeTestWithTemplateNoRender(AntaTest): - """ANTA test with template that miss the render() method""" + """ANTA test with template that miss the render() method.""" name = "FakeTestWithTemplateNoRender" description = "ANTA test with template that miss the render() method" - categories = [] - commands = [AntaTemplate(template="show interface {interface}")] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): + """Inputs for FakeTestWithTemplateNoRender test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring interface: str @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success(self.instance_commands[0].command) class FakeTestWithTemplateBadRender1(AntaTest): - """ANTA test with template that raises a AntaTemplateRenderError exception""" + """ANTA test with template that raises a AntaTemplateRenderError exception.""" name = "FakeTestWithTemplateBadRender" description = "ANTA test with template that raises a AntaTemplateRenderError exception" - categories = [] - commands = [AntaTemplate(template="show interface {interface}")] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): + """Inputs for FakeTestWithTemplateBadRender1 test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring interface: str def render(self, template: AntaTemplate) -> list[AntaCommand]: + """Render function.""" return [template.render(wrong_template_param=self.inputs.interface)] @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success(self.instance_commands[0].command) class FakeTestWithTemplateBadRender2(AntaTest): - """ANTA test with template that raises an arbitrary exception""" + """ANTA test with template that raises an arbitrary exception.""" name = "FakeTestWithTemplateBadRender2" description = "ANTA test with template that raises an arbitrary exception" - categories = [] - commands = [AntaTemplate(template="show interface {interface}")] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show interface {interface}")] + + class Input(AntaTest.Input): + """Inputs for FakeTestWithTemplateBadRender2 test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring interface: str def render(self, template: AntaTemplate) -> list[AntaCommand]: - raise Exception() # pylint: disable=broad-exception-raised + """Render function.""" + raise RuntimeError(template) @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success(self.instance_commands[0].command) class SkipOnPlatformTest(AntaTest): - """ANTA test that is skipped""" + """ANTA test that is skipped.""" name = "SkipOnPlatformTest" description = "ANTA test that is skipped on a specific platform" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @skip_on_platforms([DEVICE_HW_MODEL]) @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() class UnSkipOnPlatformTest(AntaTest): - """ANTA test that is skipped""" + """ANTA test that is skipped.""" name = "UnSkipOnPlatformTest" description = "ANTA test that is skipped on a specific platform" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @skip_on_platforms(["dummy"]) @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() class SkipOnPlatformTestWithInput(AntaTest): - """ANTA test skipped on platforms but with Input""" + """ANTA test skipped on platforms but with Input.""" name = "SkipOnPlatformTestWithInput" description = "ANTA test skipped on platforms but with Input" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] + + class Input(AntaTest.Input): + """Inputs for SkipOnPlatformTestWithInput test.""" - class Input(AntaTest.Input): # pylint: disable=missing-class-docstring string: str @skip_on_platforms([DEVICE_HW_MODEL]) @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success(self.inputs.string) class DeprecatedTestWithoutNewTest(AntaTest): - """ANTA test that is deprecated without new test""" + """ANTA test that is deprecated without new test.""" name = "DeprecatedTestWitouthNewTest" description = "ANTA test that is deprecated without new test" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @deprecated_test() @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() @@ -212,52 +245,88 @@ class DeprecatedTestWithNewTest(AntaTest): name = "DeprecatedTestWithNewTest" description = "ANTA deprecated test with New Test" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @deprecated_test(new_tests=["NewTest"]) @AntaTest.anta_test def test(self) -> None: + """Test function.""" self.result.is_success() ANTATEST_DATA: list[dict[str, Any]] = [ - {"name": "no input", "test": FakeTest, "inputs": None, "expected": {"__init__": {"result": "unset"}, "test": {"result": "success"}}}, + { + "name": "no input", + "test": FakeTest, + "inputs": None, + "expected": {"__init__": {"result": "unset"}, "test": {"result": "success"}}, + }, { "name": "extra input", "test": FakeTest, "inputs": {"string": "culpa! veniam quas quas veniam molestias, esse"}, - "expected": {"__init__": {"result": "error", "messages": ["Extra inputs are not permitted"]}, "test": {"result": "error"}}, + "expected": { + "__init__": { + "result": "error", + "messages": ["Extra inputs are not permitted"], + }, + "test": {"result": "error"}, + }, }, { "name": "no input", "test": FakeTestWithInput, "inputs": None, - "expected": {"__init__": {"result": "error", "messages": ["Field required"]}, "test": {"result": "error"}}, + "expected": { + "__init__": {"result": "error", "messages": ["Field required"]}, + "test": {"result": "error"}, + }, }, { "name": "wrong input type", "test": FakeTestWithInput, "inputs": {"string": 1}, - "expected": {"__init__": {"result": "error", "messages": ["Input should be a valid string"]}, "test": {"result": "error"}}, + "expected": { + "__init__": { + "result": "error", + "messages": ["Input should be a valid string"], + }, + "test": {"result": "error"}, + }, }, { "name": "good input", "test": FakeTestWithInput, "inputs": {"string": "culpa! veniam quas quas veniam molestias, esse"}, - "expected": {"__init__": {"result": "unset"}, "test": {"result": "success", "messages": ["culpa! veniam quas quas veniam molestias, esse"]}}, + "expected": { + "__init__": {"result": "unset"}, + "test": { + "result": "success", + "messages": ["culpa! veniam quas quas veniam molestias, esse"], + }, + }, }, { "name": "good input", "test": FakeTestWithTemplate, "inputs": {"interface": "Ethernet1"}, - "expected": {"__init__": {"result": "unset"}, "test": {"result": "success", "messages": ["show interface Ethernet1"]}}, + "expected": { + "__init__": {"result": "unset"}, + "test": {"result": "success", "messages": ["show interface Ethernet1"]}, + }, }, { "name": "wrong input type", "test": FakeTestWithTemplate, "inputs": {"interface": 1}, - "expected": {"__init__": {"result": "error", "messages": ["Input should be a valid string"]}, "test": {"result": "error"}}, + "expected": { + "__init__": { + "result": "error", + "messages": ["Input should be a valid string"], + }, + "test": {"result": "error"}, + }, }, { "name": "wrong render definition", @@ -284,13 +353,13 @@ def test(self) -> None: }, }, { - "name": "Exception in render()", + "name": "RuntimeError in render()", "test": FakeTestWithTemplateBadRender2, "inputs": {"interface": "Ethernet1"}, "expected": { "__init__": { "result": "error", - "messages": ["Exception in tests.units.test_models.FakeTestWithTemplateBadRender2.render(): Exception"], + "messages": ["Exception in tests.units.test_models.FakeTestWithTemplateBadRender2.render(): RuntimeError"], }, "test": {"result": "error"}, }, @@ -317,7 +386,10 @@ def test(self) -> None: "name": "skip on platforms, not unset", "test": SkipOnPlatformTestWithInput, "inputs": None, - "expected": {"__init__": {"result": "error", "messages": ["Field required"]}, "test": {"result": "error"}}, + "expected": { + "__init__": {"result": "error", "messages": ["Field required"]}, + "test": {"result": "error"}, + }, }, { "name": "deprecate test without new test", @@ -341,7 +413,13 @@ def test(self) -> None: "name": "failed command", "test": FakeTestWithFailedCommand, "inputs": None, - "expected": {"__init__": {"result": "unset"}, "test": {"result": "error", "messages": ["show version has failed: failed command"]}}, + "expected": { + "__init__": {"result": "unset"}, + "test": { + "result": "error", + "messages": ["show version has failed: failed command"], + }, + }, }, { "name": "unsupported command", @@ -349,29 +427,30 @@ def test(self) -> None: "inputs": None, "expected": { "__init__": {"result": "unset"}, - "test": {"result": "skipped", "messages": ["Skipped because show hardware counter drop is not supported on pytest"]}, + "test": { + "result": "skipped", + "messages": ["Skipped because show hardware counter drop is not supported on pytest"], + }, }, }, ] -class Test_AntaTest: - """ - Test for anta.models.AntaTest - """ +class TestAntaTest: + """Test for anta.models.AntaTest.""" def test__init_subclass__name(self) -> None: - """Test __init_subclass__""" + """Test __init_subclass__.""" # Pylint detects all the classes in here as unused which is on purpose # pylint: disable=unused-variable with pytest.raises(NotImplementedError) as exec_info: class WrongTestNoName(AntaTest): - """ANTA test that is missing a name""" + """ANTA test that is missing a name.""" description = "ANTA test that is missing a name" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @AntaTest.anta_test def test(self) -> None: @@ -382,11 +461,11 @@ def test(self) -> None: with pytest.raises(NotImplementedError) as exec_info: class WrongTestNoDescription(AntaTest): - """ANTA test that is missing a description""" + """ANTA test that is missing a description.""" name = "WrongTestNoDescription" - categories = [] - commands = [] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @AntaTest.anta_test def test(self) -> None: @@ -397,11 +476,11 @@ def test(self) -> None: with pytest.raises(NotImplementedError) as exec_info: class WrongTestNoCategories(AntaTest): - """ANTA test that is missing categories""" + """ANTA test that is missing categories.""" name = "WrongTestNoCategories" description = "ANTA test that is missing categories" - commands = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [] @AntaTest.anta_test def test(self) -> None: @@ -412,11 +491,11 @@ def test(self) -> None: with pytest.raises(NotImplementedError) as exec_info: class WrongTestNoCommands(AntaTest): - """ANTA test that is missing commands""" + """ANTA test that is missing commands.""" name = "WrongTestNoCommands" description = "ANTA test that is missing commands" - categories = [] + categories: ClassVar[list[str]] = [] @AntaTest.anta_test def test(self) -> None: @@ -432,14 +511,14 @@ def _assert_test(self, test: AntaTest, expected: dict[str, Any]) -> None: @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA)) def test__init__(self, device: AntaDevice, data: dict[str, Any]) -> None: - """Test the AntaTest constructor""" + """Test the AntaTest constructor.""" expected = data["expected"]["__init__"] test = data["test"](device, inputs=data["inputs"]) self._assert_test(test, expected) @pytest.mark.parametrize("data", ANTATEST_DATA, ids=generate_test_ids(ANTATEST_DATA)) def test_test(self, device: AntaDevice, data: dict[str, Any]) -> None: - """Test the AntaTest.test method""" + """Test the AntaTest.test method.""" expected = data["expected"]["test"] test = data["test"](device, inputs=data["inputs"]) asyncio.run(test.test()) @@ -454,12 +533,12 @@ def test_blacklist(device: AntaDevice, data: str) -> None: """Test for blacklisting function.""" class FakeTestWithBlacklist(AntaTest): - """Fake Test for blacklist""" + """Fake Test for blacklist.""" name = "FakeTestWithBlacklist" description = "ANTA test that has blacklisted command" - categories = [] - commands = [AntaCommand(command=data)] + categories: ClassVar[list[str]] = [] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command=data)] @AntaTest.anta_test def test(self) -> None: diff --git a/tests/units/test_runner.py b/tests/units/test_runner.py index c353cbe08..6751e676a 100644 --- a/tests/units/test_runner.py +++ b/tests/units/test_runner.py @@ -1,13 +1,11 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -test anta.runner.py -""" +"""test anta.runner.py.""" + from __future__ import annotations import logging -from typing import TYPE_CHECKING import pytest @@ -19,16 +17,12 @@ from .test_models import FakeTest -if TYPE_CHECKING: - from pytest import LogCaptureFixture - FAKE_CATALOG: AntaCatalog = AntaCatalog.from_list([(FakeTest, None)]) -@pytest.mark.asyncio -async def test_runner_empty_tests(caplog: LogCaptureFixture, test_inventory: AntaInventory) -> None: - """ - Test that when the list of tests is empty, a log is raised +@pytest.mark.asyncio() +async def test_runner_empty_tests(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: + """Test that when the list of tests is empty, a log is raised. caplog is the pytest fixture to capture logs test_inventory is a fixture that gives a default inventory for tests @@ -42,10 +36,9 @@ async def test_runner_empty_tests(caplog: LogCaptureFixture, test_inventory: Ant assert "The list of tests is empty, exiting" in caplog.records[0].message -@pytest.mark.asyncio -async def test_runner_empty_inventory(caplog: LogCaptureFixture) -> None: - """ - Test that when the Inventory is empty, a log is raised +@pytest.mark.asyncio() +async def test_runner_empty_inventory(caplog: pytest.LogCaptureFixture) -> None: + """Test that when the Inventory is empty, a log is raised. caplog is the pytest fixture to capture logs """ @@ -58,10 +51,9 @@ async def test_runner_empty_inventory(caplog: LogCaptureFixture) -> None: assert "The inventory is empty, exiting" in caplog.records[0].message -@pytest.mark.asyncio -async def test_runner_no_selected_device(caplog: LogCaptureFixture, test_inventory: AntaInventory) -> None: - """ - Test that when the list of established device +@pytest.mark.asyncio() +async def test_runner_no_selected_device(caplog: pytest.LogCaptureFixture, test_inventory: AntaInventory) -> None: + """Test that when the list of established device. caplog is the pytest fixture to capture logs test_inventory is a fixture that gives a default inventory for tests diff --git a/tests/units/tools/__init__.py b/tests/units/tools/__init__.py index e772bee41..a5e027896 100644 --- a/tests/units/tools/__init__.py +++ b/tests/units/tools/__init__.py @@ -1,3 +1,4 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. +"""Test anta.tools.""" diff --git a/tests/units/tools/test_get_dict_superset.py b/tests/units/tools/test_get_dict_superset.py index 63e08b525..e02c046d0 100644 --- a/tests/units/tools/test_get_dict_superset.py +++ b/tests/units/tools/test_get_dict_superset.py @@ -1,10 +1,11 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. - """Tests for `anta.tools.get_dict_superset`.""" + from __future__ import annotations +from contextlib import AbstractContextManager from contextlib import nullcontext as does_not_raise from typing import Any @@ -37,9 +38,28 @@ @pytest.mark.parametrize( - "list_of_dicts, input_dict, default, required, var_name, custom_error_msg, expected_result, expected_raise", + ( + "list_of_dicts", + "input_dict", + "default", + "required", + "var_name", + "custom_error_msg", + "expected_result", + "expected_raise", + ), [ - pytest.param([], {"id": 1, "name": "Alice"}, None, False, None, None, None, does_not_raise(), id="empty list"), + pytest.param( + [], + {"id": 1, "name": "Alice"}, + None, + False, + None, + None, + None, + does_not_raise(), + id="empty list", + ), pytest.param( [], {"id": 1, "name": "Alice"}, @@ -51,11 +71,49 @@ pytest.raises(ValueError, match="not found in the provided list."), id="empty list and required", ), - pytest.param(DUMMY_DATA, {"id": 10, "name": "Jack"}, None, False, None, None, None, does_not_raise(), id="missing item"), - pytest.param(DUMMY_DATA, {"id": 1, "name": "Alice"}, None, False, None, None, DUMMY_DATA[1], does_not_raise(), id="found item"), - pytest.param(DUMMY_DATA, {"id": 10, "name": "Jack"}, "default_value", False, None, None, "default_value", does_not_raise(), id="default value"), pytest.param( - DUMMY_DATA, {"id": 10, "name": "Jack"}, None, True, None, None, None, pytest.raises(ValueError, match="not found in the provided list."), id="required" + DUMMY_DATA, + {"id": 10, "name": "Jack"}, + None, + False, + None, + None, + None, + does_not_raise(), + id="missing item", + ), + pytest.param( + DUMMY_DATA, + {"id": 1, "name": "Alice"}, + None, + False, + None, + None, + DUMMY_DATA[1], + does_not_raise(), + id="found item", + ), + pytest.param( + DUMMY_DATA, + {"id": 10, "name": "Jack"}, + "default_value", + False, + None, + None, + "default_value", + does_not_raise(), + id="default value", + ), + pytest.param( + DUMMY_DATA, + {"id": 10, "name": "Jack"}, + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="not found in the provided list."), + id="required", ), pytest.param( DUMMY_DATA, @@ -69,7 +127,15 @@ id="custom var_name", ), pytest.param( - DUMMY_DATA, {"id": 1, "name": "Alice"}, None, True, "custom_var_name", "Custom error message", DUMMY_DATA[1], does_not_raise(), id="custom error message" + DUMMY_DATA, + {"id": 1, "name": "Alice"}, + None, + True, + "custom_var_name", + "Custom error message", + DUMMY_DATA[1], + does_not_raise(), + id="custom error message", ), pytest.param( DUMMY_DATA, @@ -82,7 +148,17 @@ pytest.raises(ValueError, match="Custom error message"), id="custom error message and required", ), - pytest.param(DUMMY_DATA, {"id": 1, "name": "Jack"}, None, False, None, None, None, does_not_raise(), id="id ok but name not ok"), + pytest.param( + DUMMY_DATA, + {"id": 1, "name": "Jack"}, + None, + False, + None, + None, + None, + does_not_raise(), + id="id ok but name not ok", + ), pytest.param( "not a list", {"id": 1, "name": "Alice"}, @@ -95,9 +171,27 @@ id="non-list input for list_of_dicts", ), pytest.param( - DUMMY_DATA, "not a dict", None, True, None, None, None, pytest.raises(ValueError, match="not found in the provided list."), id="non-dictionary input" + DUMMY_DATA, + "not a dict", + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="not found in the provided list."), + id="non-dictionary input", + ), + pytest.param( + DUMMY_DATA, + {}, + None, + False, + None, + None, + None, + does_not_raise(), + id="empty dictionary input", ), - pytest.param(DUMMY_DATA, {}, None, False, None, None, None, does_not_raise(), id="empty dictionary input"), pytest.param( DUMMY_DATA, {"id": 1, "name": "Alice", "extra_key": "extra_value"}, @@ -122,7 +216,13 @@ ), pytest.param( DUMMY_DATA, - {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com", "extra_key": "extra_value"}, + { + "id": 1, + "name": "Alice", + "age": 30, + "email": "alice@example.com", + "extra_key": "extra_value", + }, None, True, None, @@ -135,15 +235,15 @@ ) def test_get_dict_superset( list_of_dicts: list[dict[Any, Any]], - input_dict: Any, - default: Any | None, + input_dict: dict[Any, Any], + default: str | None, required: bool, var_name: str | None, custom_error_msg: str | None, expected_result: str, - expected_raise: Any, + expected_raise: AbstractContextManager[Exception], ) -> None: """Test get_dict_superset.""" # pylint: disable=too-many-arguments with expected_raise: - assert get_dict_superset(list_of_dicts, input_dict, default, required, var_name, custom_error_msg) == expected_result + assert get_dict_superset(list_of_dicts, input_dict, default, var_name, custom_error_msg, required=required) == expected_result diff --git a/tests/units/tools/test_get_item.py b/tests/units/tools/test_get_item.py index 7d75e9c2a..84590de01 100644 --- a/tests/units/tools/test_get_item.py +++ b/tests/units/tools/test_get_item.py @@ -3,8 +3,10 @@ # that can be found in the LICENSE file. """Tests for `anta.tools.get_item`.""" + from __future__ import annotations +from contextlib import AbstractContextManager from contextlib import nullcontext as does_not_raise from typing import Any @@ -36,7 +38,7 @@ @pytest.mark.parametrize( - "list_of_dicts, key, value, default, required, case_sensitive, var_name, custom_error_msg, expected_result, expected_raise", + ("list_of_dicts", "key", "value", "default", "required", "case_sensitive", "var_name", "custom_error_msg", "expected_result", "expected_raise"), [ pytest.param([], "name", "Bob", None, False, False, None, None, None, does_not_raise(), id="empty list"), pytest.param([], "name", "Bob", None, True, False, None, None, None, pytest.raises(ValueError, match="name"), id="empty list and required"), @@ -56,17 +58,17 @@ ) def test_get_item( list_of_dicts: list[dict[Any, Any]], - key: Any, - value: Any, - default: Any | None, + key: str, + value: str | None, + default: str | None, required: bool, case_sensitive: bool, var_name: str | None, custom_error_msg: str | None, expected_result: str, - expected_raise: Any, + expected_raise: AbstractContextManager[Exception], ) -> None: """Test get_item.""" # pylint: disable=too-many-arguments with expected_raise: - assert get_item(list_of_dicts, key, value, default, required, case_sensitive, var_name, custom_error_msg) == expected_result + assert get_item(list_of_dicts, key, value, default, var_name, custom_error_msg, required=required, case_sensitive=case_sensitive) == expected_result diff --git a/tests/units/tools/test_get_value.py b/tests/units/tools/test_get_value.py index 73344d1eb..139f67fc6 100644 --- a/tests/units/tools/test_get_value.py +++ b/tests/units/tools/test_get_value.py @@ -1,12 +1,11 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. -""" -Tests for anta.tools.get_value -""" +"""Tests for anta.tools.get_value.""" from __future__ import annotations +from contextlib import AbstractContextManager from contextlib import nullcontext as does_not_raise from typing import Any @@ -18,16 +17,95 @@ @pytest.mark.parametrize( - "input_dict, key, default, required, org_key, separator, expected_result, expected_raise", + ( + "input_dict", + "key", + "default", + "required", + "org_key", + "separator", + "expected_result", + "expected_raise", + ), [ pytest.param({}, "test", None, False, None, None, None, does_not_raise(), id="empty dict"), - pytest.param(INPUT_DICT, "test_value", None, False, None, None, 42, does_not_raise(), id="simple key"), - pytest.param(INPUT_DICT, "nested_test.nested_value", None, False, None, None, 43, does_not_raise(), id="nested_key"), - pytest.param(INPUT_DICT, "missing_value", None, False, None, None, None, does_not_raise(), id="missing_value"), - pytest.param(INPUT_DICT, "missing_value_with_default", "default_value", False, None, None, "default_value", does_not_raise(), id="default"), - pytest.param(INPUT_DICT, "missing_required", None, True, None, None, None, pytest.raises(ValueError), id="required"), - pytest.param(INPUT_DICT, "missing_required", None, True, "custom_org_key", None, None, pytest.raises(ValueError), id="custom org_key"), - pytest.param(INPUT_DICT, "nested_test||nested_value", None, None, None, "||", 43, does_not_raise(), id="custom separator"), + pytest.param( + INPUT_DICT, + "test_value", + None, + False, + None, + None, + 42, + does_not_raise(), + id="simple key", + ), + pytest.param( + INPUT_DICT, + "nested_test.nested_value", + None, + False, + None, + None, + 43, + does_not_raise(), + id="nested_key", + ), + pytest.param( + INPUT_DICT, + "missing_value", + None, + False, + None, + None, + None, + does_not_raise(), + id="missing_value", + ), + pytest.param( + INPUT_DICT, + "missing_value_with_default", + "default_value", + False, + None, + None, + "default_value", + does_not_raise(), + id="default", + ), + pytest.param( + INPUT_DICT, + "missing_required", + None, + True, + None, + None, + None, + pytest.raises(ValueError, match="missing_required"), + id="required", + ), + pytest.param( + INPUT_DICT, + "missing_required", + None, + True, + "custom_org_key", + None, + None, + pytest.raises(ValueError, match="custom_org_key"), + id="custom org_key", + ), + pytest.param( + INPUT_DICT, + "nested_test||nested_value", + None, + None, + None, + "||", + 43, + does_not_raise(), + id="custom separator", + ), ], ) def test_get_value( @@ -37,14 +115,17 @@ def test_get_value( required: bool, org_key: str | None, separator: str | None, - expected_result: str, - expected_raise: Any, + expected_result: int | str | None, + expected_raise: AbstractContextManager[Exception], ) -> None: - """ - Test get_value - """ + """Test get_value.""" # pylint: disable=too-many-arguments - kwargs = {"default": default, "required": required, "org_key": org_key, "separator": separator} + kwargs = { + "default": default, + "required": required, + "org_key": org_key, + "separator": separator, + } kwargs = {k: v for k, v in kwargs.items() if v is not None} with expected_raise: - assert get_value(input_dict, key, **kwargs) == expected_result # type: ignore + assert get_value(input_dict, key, **kwargs) == expected_result # type: ignore[arg-type] diff --git a/tests/units/tools/test_utils.py b/tests/units/tools/test_utils.py index 448324f87..2cd9cd1f4 100644 --- a/tests/units/tools/test_utils.py +++ b/tests/units/tools/test_utils.py @@ -1,11 +1,10 @@ # Copyright (c) 2023-2024 Arista Networks, Inc. # Use of this source code is governed by the Apache License 2.0 # that can be found in the LICENSE file. - """Tests for `anta.tools.utils`.""" + from __future__ import annotations -from contextlib import nullcontext as does_not_raise from typing import Any import pytest @@ -28,15 +27,19 @@ @pytest.mark.parametrize( - "expected_output, actual_output, expected_result, expected_raise", + ("expected_output", "actual_output", "expected_result"), [ - pytest.param(EXPECTED_OUTPUTS[0], ACTUAL_OUTPUTS[0], "", does_not_raise(), id="no difference"), + pytest.param( + EXPECTED_OUTPUTS[0], + ACTUAL_OUTPUTS[0], + "", + id="no difference", + ), pytest.param( EXPECTED_OUTPUTS[0], ACTUAL_OUTPUTS[1], "\nExpected `1` as the id, but found `2` instead.\nExpected `Alice` as the name, but found `Bob` instead.\n" "Expected `30` as the age, but found `35` instead.\nExpected `alice@example.com` as the email, but found `bob@example.com` instead.", - does_not_raise(), id="different data", ), pytest.param( @@ -45,13 +48,20 @@ "\nExpected `1` as the id, but it was not found in the actual output.\nExpected `Alice` as the name, but it was not found in the actual output.\n" "Expected `30` as the age, but it was not found in the actual output.\nExpected `alice@example.com` as the email, but it was not found in " "the actual output.", - does_not_raise(), id="empty actual output", ), - pytest.param(EXPECTED_OUTPUTS[3], ACTUAL_OUTPUTS[3], "\nExpected `Jon` as the name, but found `Rob` instead.", does_not_raise(), id="different name"), + pytest.param( + EXPECTED_OUTPUTS[3], + ACTUAL_OUTPUTS[3], + "\nExpected `Jon` as the name, but found `Rob` instead.", + id="different name", + ), ], ) -def test_get_failed_logs(expected_output: dict[Any, Any], actual_output: dict[Any, Any], expected_result: str, expected_raise: Any) -> None: +def test_get_failed_logs( + expected_output: dict[Any, Any], + actual_output: dict[Any, Any], + expected_result: str, +) -> None: """Test get_failed_logs.""" - with expected_raise: - assert get_failed_logs(expected_output, actual_output) == expected_result + assert get_failed_logs(expected_output, actual_output) == expected_result