Skip to content

Commit cf1cf61

Browse files
committed
Merge branch '57-execute-nuts-without-need-of-an-actual-network' into 'master'
Generalise NutsContext and add offline showcase Closes #57 and #60 See merge request ins/nettowel/nettowel-nuts!27
2 parents 7238fca + 2f33081 commit cf1cf61

19 files changed

Lines changed: 165 additions & 86 deletions

mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[mypy]
2-
python_version = 3.6
2+
python_version = 3.7
33

44
### --strict
55
# warn_unused_configs = True

nuts/base_tests/napalm_get_users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,4 @@ def test_privilege_level(self, single_result, username, level):
5151
class TestNapalmOnlyDefinedUsersExist:
5252
@pytest.mark.nuts("host,usernames")
5353
def test_no_rogue_users(self, single_result, usernames):
54-
assert list(single_result.result.keys()) == usernames
54+
assert list(single_result.result) == usernames

nuts/base_tests/napalm_network_instances.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def _extract_route_distinguisher(self, element: dict) -> str:
4343
return element["state"]["route_distinguisher"]
4444

4545
def _extract_interfaces(self, element: dict) -> List[str]:
46-
return list(element["interfaces"]["interface"].keys())
46+
return list(element["interfaces"]["interface"])
4747

4848

4949
CONTEXT = NetworkInstancesContext

nuts/base_tests/napalm_ping.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,10 @@ def napalm_ping_multi_dests(self, task: Task, **kwargs) -> Result:
8383
@pytest.mark.usefixtures("check_nuts_result")
8484
class TestNapalmPing:
8585
@pytest.fixture
86-
def single_result(self, nornir_nuts_ctx: NornirNutsContext, host: str, destination: str) -> NutsResult:
87-
transformed_result = nornir_nuts_ctx.transformed_result()
88-
assert host in transformed_result, f"Host {host} not found in aggregated result."
89-
assert destination in transformed_result[host], f"Destination {destination} not found in result."
90-
return transformed_result[host][destination]
86+
def single_result(self, nuts_ctx: NornirNutsContext, host: str, destination: str) -> NutsResult:
87+
assert host in nuts_ctx.transformed_result, f"Host {host} not found in aggregated result."
88+
assert destination in nuts_ctx.transformed_result[host], f"Destination {destination} not found in result."
89+
return nuts_ctx.transformed_result[host][destination]
9190

9291
@pytest.mark.nuts("host,destination,expected")
9392
def test_ping(self, single_result, expected):

nuts/base_tests/netmiko_iperf.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,10 @@ def teardown(self) -> None:
7676
@pytest.mark.usefixtures("check_nuts_result")
7777
class TestNetmikoIperf:
7878
@pytest.fixture
79-
def single_result(self, nornir_nuts_ctx, host, destination):
80-
result = nornir_nuts_ctx.transformed_result()
81-
assert host in result, f"Host {host} not found in aggregated result."
82-
assert destination in result[host], f"Destination {destination} not found in result."
83-
return result[host][destination]
79+
def single_result(self, nuts_ctx: NornirNutsContext, host, destination):
80+
assert host in nuts_ctx.transformed_result, f"Host {host} not found in aggregated result."
81+
assert destination in nuts_ctx.transformed_result[host], f"Destination {destination} not found in result."
82+
return nuts_ctx.transformed_result[host][destination]
8483

8584
@pytest.mark.nuts("host,destination,min_expected")
8685
def test_iperf(self, single_result, min_expected):

nuts/context.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""Provide necessary information that is needed for a specific test."""
2-
from typing import Any, Callable, Optional
2+
import pathlib
3+
from typing import Any, Callable, Optional, Dict, Union
34

5+
from nornir import InitNornir
46
from nornir.core import Nornir
57
from nornir.core.task import AggregatedResult
68

79
from nuts.helpers.errors import NutsSetupError
10+
from nuts.helpers.result import NutsResult
11+
12+
13+
_TransformedResult = Dict[str, Any]
814

915

1016
class NutsContext:
@@ -16,6 +22,11 @@ class NutsContext:
1622

1723
def __init__(self, nuts_parameters: Any = None):
1824
self.nuts_parameters = nuts_parameters or {}
25+
self._cached_result: Optional[_TransformedResult] = None
26+
27+
def initialize(self) -> None:
28+
"""Initialize dependencies for this context after it has been created."""
29+
pass
1930

2031
def nuts_arguments(self) -> dict:
2132
"""
@@ -30,6 +41,30 @@ def nuts_arguments(self) -> dict:
3041
test_execution = self.nuts_parameters.get("test_execution", None)
3142
return {**(test_execution if test_execution is not None else {})}
3243

44+
def general_result(self) -> Any:
45+
"""
46+
:return: raw, unprocessed result
47+
"""
48+
raise NotImplementedError
49+
50+
def transform_result(self, general_result: Any) -> _TransformedResult:
51+
"""
52+
:param general_result: raw result
53+
:return: processed result ready to be passed to a test
54+
"""
55+
raise NotImplementedError
56+
57+
@property
58+
def transformed_result(self) -> _TransformedResult:
59+
"""
60+
The (processed) results of the network task, ready to be passed on to a test's fixture.
61+
The results are cached, so that general_result does not need to be called multiple times as it might
62+
access the network.
63+
"""
64+
if self._cached_result is None:
65+
self._cached_result = self.transform_result(self.general_result())
66+
return self._cached_result
67+
3368

3469
class NornirNutsContext(NutsContext):
3570
"""
@@ -41,11 +76,20 @@ class NornirNutsContext(NutsContext):
4176
4277
"""
4378

79+
#: The path to a nornir configuration file.
80+
#: https://nornir.readthedocs.io/en/stable/configuration/index.html
81+
NORNIR_CONFIG_FILE = pathlib.Path("nr-config.yaml")
82+
4483
def __init__(self, nuts_parameters: Any = None):
4584
super().__init__(nuts_parameters)
46-
self._transformed_result = None
4785
self.nornir: Optional[Nornir] = None
4886

87+
def initialize(self) -> None:
88+
self.nornir = InitNornir(
89+
config_file=str(self.NORNIR_CONFIG_FILE),
90+
logging={"enabled": False},
91+
)
92+
4993
def nuts_task(self) -> Callable:
5094
"""
5195
Returns the task that nornir should execute for the test module.
@@ -60,14 +104,14 @@ def nornir_filter(self):
60104
"""
61105
return None
62106

63-
def transform_result(self, general_result: AggregatedResult) -> Any:
107+
def transform_result(self, general_result: AggregatedResult) -> Dict[str, Any]:
64108
"""
65109
Transforms the raw nornir result and wraps it into a `NutsResult`.
66110
67111
:param general_result: The raw answer as provided by nornir's executed task
68-
:return: Usually a dict where keys are the hosts, values are a `NutsResult`
112+
:return: A dict where keys are the hosts, values are a `NutsResult`
69113
"""
70-
return general_result
114+
raise NotImplementedError
71115

72116
def general_result(self) -> AggregatedResult:
73117
"""
@@ -102,13 +146,3 @@ def teardown(self):
102146
Defines code which is executed after the nornir task.
103147
"""
104148
pass
105-
106-
def transformed_result(self) -> Any:
107-
"""
108-
The result from nornir's task, transformed to be passed on later to a test's fixture
109-
called `single_result`.
110-
:return: The transformed result
111-
"""
112-
if not self._transformed_result:
113-
self._transformed_result = self.transform_result(self.general_result())
114-
return self._transformed_result

nuts/plugin.py

Lines changed: 9 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,73 +7,35 @@
77
from _pytest.python import Metafunc
88
from _pytest.fixtures import FixtureRequest
99
from _pytest.config import Config
10-
from nornir import InitNornir
11-
from nornir.core import Nornir
1210
from py._path.local import LocalPath
13-
from nuts.helpers.errors import NutsSetupError
1411

15-
from nuts.context import NutsContext, NornirNutsContext
12+
from nuts.context import NutsContext
1613
from nuts.helpers.result import NutsResult
1714
from nuts.yamlloader import NutsYamlFile, get_parametrize_data
1815

1916

20-
@pytest.fixture(scope="session")
21-
def nornir_config_file() -> str:
22-
"""
23-
Returns the filename to a nornir configuration file.
24-
https://nornir.readthedocs.io/en/stable/configuration/index.html
25-
26-
:return: The filename of the configuration
27-
"""
28-
return "nr-config.yaml"
29-
30-
31-
@pytest.fixture(scope="session")
32-
def initialized_nornir(nornir_config_file: str) -> Nornir:
33-
"""
34-
Initalizes nornir with a provided configuration file.
35-
36-
:param nornir_config_file: The filename of a nornir configuration file
37-
:return: An initialized nornir instance
38-
"""
39-
return InitNornir(config_file=nornir_config_file, logging={"enabled": False})
40-
41-
4217
@pytest.fixture(scope="class")
4318
def nuts_ctx(request: FixtureRequest) -> NutsContext:
4419
params = request.node.params
4520
context_class = getattr(request.module, "CONTEXT", NutsContext)
46-
return context_class(params)
47-
48-
49-
@pytest.fixture(scope="class")
50-
def nornir_nuts_ctx(nuts_ctx: NutsContext, initialized_nornir: Nornir) -> NornirNutsContext:
51-
"""
52-
Injects an initialized nornir instance in the context of a test.
53-
54-
:param nuts_ctx: The context to which the nornir instance should be added
55-
:param initialized_nornir: The nornir instance
56-
:return: A NornirNutsContext with an initialized nornir instance
57-
"""
58-
if not isinstance(nuts_ctx, NornirNutsContext):
59-
raise NutsSetupError("The initialized context does not support the injection of nornir.")
60-
nuts_ctx.nornir = initialized_nornir
61-
return nuts_ctx
21+
ctx = context_class(params)
22+
ctx.initialize()
23+
return ctx
6224

6325

6426
@pytest.fixture
65-
def single_result(nornir_nuts_ctx: NornirNutsContext, host: str) -> NutsResult:
27+
def single_result(nuts_ctx: NutsContext, host: str) -> NutsResult:
6628
"""
6729
Returns the result which belongs to a specific host out of the overall set of results
6830
that has been returned by nornir's task.
6931
7032
:param nornir_nuts_ctx: The context for a test with an initialized nornir instance
71-
:param host: The host for which the corresponding result should be returned
33+
:param host: The host from the test bundle (yaml-file) for which the corresponding result should be returned
34+
:param destination: The corresponding destination to a host for tests that test a host-destination relationship
7235
:return: The `NutsResult` that belongs to a host
7336
"""
74-
result = nornir_nuts_ctx.transformed_result()
75-
assert host in result, f"Host {host} not found in aggregated result."
76-
return result[host]
37+
assert host in nuts_ctx.transformed_result, f"Host {host} not found in aggregated result."
38+
return nuts_ctx.transformed_result[host]
7739

7840

7941
@pytest.fixture

tests/base_tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from _pytest.fixtures import FixtureRequest
55
from napalm.base.exceptions import ConnectionException
66
from nornir.core.task import AggregatedResult
7-
from nuts.helpers.result import NutsResult
87

98
from nuts.context import NornirNutsContext, NutsContext
109

tests/base_tests/test_napalm_bgp_neighbors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def test_contains_hosts_at_toplevel(self, transformed_result, host):
8383

8484
@pytest.mark.parametrize("host, neighbors", [("R1", ["172.16.255.2", "172.16.255.3"]), ("R2", ["172.16.255.1"])])
8585
def test_contains_peers_at_second_level(self, transformed_result, host, neighbors):
86-
assert list(transformed_result[host].result.keys()) == neighbors
86+
assert list(transformed_result[host].result) == neighbors
8787

8888
@pytest.mark.parametrize("host, neighbor, details", [("R1", "172.16.255.2", neighbor_details)])
8989
def test_contains_information_about_neighbor(self, transformed_result, host, neighbor, details):

tests/base_tests/test_napalm_interfaces.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def test_contains_host_at_toplevel(self, transformed_result, host):
7676
[("R1", "GigabitEthernet1"), ("R1", "GigabitEthernet2"), ("R2", "Loopback0"), ("R2", "GigabitEthernet3")],
7777
)
7878
def test_contains_interface_names_at_second_level(self, transformed_result, host, interface_name):
79-
assert interface_name in transformed_result[host].result.keys()
79+
assert interface_name in transformed_result[host].result
8080

8181
@pytest.mark.parametrize(
8282
"host, name, is_enabled, is_up, mac_address",

0 commit comments

Comments
 (0)