From 08a9df6528f66763a3924afc6f9021e32eb53117 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 11:43:52 -0400 Subject: [PATCH 1/9] Added yamlized unittests --- nautobot_design_builder/tests/test_builder.py | 56 +++--- .../tests/testdata/assign_tags_by_name.yaml | 19 ++ .../tests/testdata/assign_tags_by_ref.yaml | 20 +++ .../tests/testdata/base_test.yaml | 42 +++++ .../tests/testdata/complex_design1.yaml | 167 ++++++++++++++++++ .../testdata/create_or_update_by_ref.yaml | 63 +++++++ .../tests/testdata/create_or_update_mlag.yaml | 41 +++++ .../tests/testdata/create_or_update_rack.yaml | 19 ++ .../create_or_update_relationships.yaml | 21 +++ .../tests/testdata/create_tags.yaml | 9 + .../testdata/custom_relationship_by_key.yaml | 26 +-- .../custom_relationship_by_label.yaml | 26 +-- .../tests/testdata/device_primary_ip.yaml | 28 +++ .../tests/testdata/interface_addresses.yaml | 28 +-- .../tests/testdata/many_to_many.yaml | 7 +- .../tests/testdata/nested_create.yaml | 30 +--- .../tests/testdata/nested_update.yaml | 33 ++++ .../tests/testdata/one_to_one.yaml | 41 +++++ .../tests/testdata/prefixes.yaml | 21 +-- 19 files changed, 557 insertions(+), 140 deletions(-) create mode 100644 nautobot_design_builder/tests/testdata/assign_tags_by_name.yaml create mode 100644 nautobot_design_builder/tests/testdata/assign_tags_by_ref.yaml create mode 100644 nautobot_design_builder/tests/testdata/base_test.yaml create mode 100644 nautobot_design_builder/tests/testdata/complex_design1.yaml create mode 100644 nautobot_design_builder/tests/testdata/create_or_update_by_ref.yaml create mode 100644 nautobot_design_builder/tests/testdata/create_or_update_mlag.yaml create mode 100644 nautobot_design_builder/tests/testdata/create_or_update_rack.yaml create mode 100644 nautobot_design_builder/tests/testdata/create_or_update_relationships.yaml create mode 100644 nautobot_design_builder/tests/testdata/create_tags.yaml create mode 100644 nautobot_design_builder/tests/testdata/device_primary_ip.yaml create mode 100644 nautobot_design_builder/tests/testdata/nested_update.yaml create mode 100644 nautobot_design_builder/tests/testdata/one_to_one.yaml diff --git a/nautobot_design_builder/tests/test_builder.py b/nautobot_design_builder/tests/test_builder.py index 4297b494..d5141843 100644 --- a/nautobot_design_builder/tests/test_builder.py +++ b/nautobot_design_builder/tests/test_builder.py @@ -3,7 +3,7 @@ import importlib from operator import attrgetter import os -from unittest.mock import Mock, patch +from unittest.mock import patch import yaml from django.db.models import Manager, Q @@ -109,36 +109,46 @@ def builder_test_case(data_dir): """Decorator to load tests into a TestCase from a data directory.""" def class_wrapper(test_class): + def _run_test_case(self, testcase, data_dir): + with patch("nautobot_design_builder.design.Environment.roll_back") as roll_back: + depends_on = testcase.pop("depends_on", None) + if depends_on: + depends_on_path = os.path.join(data_dir, depends_on) + depends_on_dir = os.path.dirname(depends_on_path) + with open(depends_on_path, encoding="utf-8") as file: + self._run_test_case(yaml.safe_load(file), depends_on_dir) + + extensions = [] + for extension in testcase.get("extensions", []): + extensions.append(_load_class(extension)) + + with self.captureOnCommitCallbacks(execute=True): + for design in testcase["designs"]: + environment = Environment(extensions=extensions) + commit = design.pop("commit", True) + environment.implement_design(design=design, commit=commit) + if not commit: + roll_back.assert_called() + + for index, check in enumerate(testcase.get("checks", [])): + for check_name, args in check.items(): + _check_name = f"check_{check_name}" + if hasattr(BuilderChecks, _check_name): + getattr(BuilderChecks, _check_name)(self, args, index) + else: + raise ValueError(f"Unknown check {check_name} {check}") + setattr(test_class, "_run_test_case", _run_test_case) + for testcase, filename in _testcases(data_dir): # Strip the .yaml extension testcase_name = f"test_{filename[:-5]}" # Create a new closure for testcase def test_wrapper(testcase): - @patch("nautobot_design_builder.design.Environment.roll_back") - def test_runner(self, roll_back: Mock): + def test_runner(self): if testcase.get("skip", False): self.skipTest("Skipping due to testcase skip=true") - extensions = [] - for extension in testcase.get("extensions", []): - extensions.append(_load_class(extension)) - - with self.captureOnCommitCallbacks(execute=True): - for design in testcase["designs"]: - environment = Environment(extensions=extensions) - commit = design.pop("commit", True) - environment.implement_design(design=design, commit=commit) - if not commit: - roll_back.assert_called() - - for index, check in enumerate(testcase.get("checks", [])): - for check_name, args in check.items(): - _check_name = f"check_{check_name}" - if hasattr(BuilderChecks, _check_name): - getattr(BuilderChecks, _check_name)(self, args, index) - else: - raise ValueError(f"Unknown check {check_name} {check}") - + self._run_test_case(testcase, data_dir) return test_runner setattr(test_class, testcase_name, test_wrapper(testcase)) diff --git a/nautobot_design_builder/tests/testdata/assign_tags_by_name.yaml b/nautobot_design_builder/tests/testdata/assign_tags_by_name.yaml new file mode 100644 index 00000000..33e48eb9 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/assign_tags_by_name.yaml @@ -0,0 +1,19 @@ +--- +depends_on: "base_test.yaml" +designs: + - tags: + - name: "Test Tag" + description: "Some Description" + + locations: + - name: "site_1" + location_type__name: "Site" + status__name: "Active" + tags: + - {"!get:name": "Test Tag"} +checks: + - equal: + - model: "nautobot.dcim.models.Location" + query: {name: "site_1"} + attribute: "tags" + - model: "nautobot.extras.models.Tag" diff --git a/nautobot_design_builder/tests/testdata/assign_tags_by_ref.yaml b/nautobot_design_builder/tests/testdata/assign_tags_by_ref.yaml new file mode 100644 index 00000000..fc24ac01 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/assign_tags_by_ref.yaml @@ -0,0 +1,20 @@ +--- +depends_on: "base_test.yaml" +designs: + - tags: + - name: "Test Tag" + "!ref": "test_tag" + description: "Some Description" + + locations: + - name: "site_1" + location_type__name: "Site" + status__name: "Active" + tags: + - "!ref:test_tag" +checks: + - equal: + - model: "nautobot.dcim.models.Location" + query: {name: "site_1"} + attribute: "tags" + - model: "nautobot.extras.models.Tag" diff --git a/nautobot_design_builder/tests/testdata/base_test.yaml b/nautobot_design_builder/tests/testdata/base_test.yaml new file mode 100644 index 00000000..578af6a4 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/base_test.yaml @@ -0,0 +1,42 @@ +--- +skip: true +designs: + - manufacturers: + - name: "manufacturer1" + + device_types: + - manufacturer__name: "manufacturer1" + model: "model name" + u_height: 1 + + roles: + - name: "device role" + content_types: + - "!get:app_label": "dcim" + "!get:model": "device" + + location_types: + - name: "Site" + content_types: + - "!get:app_label": "circuits" + "!get:model": "circuittermination" + - "!get:app_label": "dcim" + "!get:model": "device" + - "!get:app_label": "dcim" + "!get:model": "powerpanel" + - "!get:app_label": "dcim" + "!get:model": "rack" + - "!get:app_label": "dcim" + "!get:model": "rackgroup" + - "!get:app_label": "ipam" + "!get:model": "prefix" + - "!get:app_label": "ipam" + "!get:model": "vlan" + - "!get:app_label": "ipam" + "!get:model": "vlangroup" + - "!get:app_label": "virtualization" + "!get:model": "cluster" + locations: + - "name": "Site" + "location_type__name": "Site" + "status__name": "Active" diff --git a/nautobot_design_builder/tests/testdata/complex_design1.yaml b/nautobot_design_builder/tests/testdata/complex_design1.yaml new file mode 100644 index 00000000..b145c869 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/complex_design1.yaml @@ -0,0 +1,167 @@ +--- +depends_on: "base_test.yaml" +checks: + # Spine 1 to Leaf 1 + - connected: + - model: "nautobot.dcim.models.Interface" + query: {device__name: "spine1", name: "Ethernet9/3"} + - model: "nautobot.dcim.models.Interface" + query: {device__name: "leaf1", name: "Ethernet33/1"} + + # Spine 1 to Leaf 2 + - connected: + - model: "nautobot.dcim.models.Interface" + query: {device__name: "spine1", name: "Ethernet25/3"} + - model: "nautobot.dcim.models.Interface" + query: {device__name: "leaf2", name: "Ethernet33/1"} + + # Spine 2 to Leaf 1 + - connected: + - model: "nautobot.dcim.models.Interface" + query: {device__name: "spine2", name: "Ethernet9/3"} + - model: "nautobot.dcim.models.Interface" + query: {device__name: "leaf1", name: "Ethernet34/1"} + + # Spine 2 to Leaf 2 + - connected: + - model: "nautobot.dcim.models.Interface" + query: {device__name: "spine2", name: "Ethernet25/3"} + - model: "nautobot.dcim.models.Interface" + query: {device__name: "leaf2", name: "Ethernet34/1"} + + # Spine 3 to Leaf 1 + - connected: + - model: "nautobot.dcim.models.Interface" + query: {device__name: "spine3", name: "Ethernet9/3"} + - model: "nautobot.dcim.models.Interface" + query: {device__name: "leaf1", name: "Ethernet35/1"} + + # Spine 3 to Leaf 2 + - connected: + - model: "nautobot.dcim.models.Interface" + query: {device__name: "spine3", name: "Ethernet25/3"} + - model: "nautobot.dcim.models.Interface" + query: {device__name: "leaf2", name: "Ethernet35/1"} +designs: + - roles: + - "name": "EVPN Leaf" + content_types: + - "!get:app_label": "dcim" + "!get:model": "device" + - "name": "EVPN Spine" + content_types: + - "!get:app_label": "dcim" + "!get:model": "device" + + devices: + # Create Spine Switches + - "!create_or_update:name": "spine1" + "status__name": "Active" + "location__name": "Site" + "role__name": "EVPN Spine" + "device_type__model": "model name" + "interfaces": + - "!create_or_update:name": "Ethernet9/3" + "type": "100gbase-x-qsfp28" + "status__name": "Active" + "!ref": "spine1_to_leaf1" + - "!create_or_update:name": "Ethernet25/3" + "type": "100gbase-x-qsfp28" + "status__name": "Active" + "!ref": "spine1_to_leaf2" + - "!create_or_update:name": "spine2" + "status__name": "Active" + "location__name": "Site" + "role__name": "EVPN Spine" + "device_type__model": "model name" + "interfaces": + - "!create_or_update:name": "Ethernet9/3" + "type": "100gbase-x-qsfp28" + "status__name": "Active" + "!ref": "spine2_to_leaf1" + - "!create_or_update:name": "Ethernet25/3" + "type": "100gbase-x-qsfp28" + "status__name": "Active" + "!ref": "spine2_to_leaf2" + - "!create_or_update:name": "spine3" + "status__name": "Active" + "location__name": "Site" + "role__name": "EVPN Spine" + "device_type__model": "model name" + "interfaces": + - "!create_or_update:name": "Ethernet9/3" + "type": "100gbase-x-qsfp28" + "status__name": "Active" + "!ref": "spine3_to_leaf1" + - "!create_or_update:name": "Ethernet25/3" + "type": "100gbase-x-qsfp28" + "status__name": "Active" + "!ref": "spine3_to_leaf2" + - "!create_or_update:name": "leaf1" + "status__name": "Active" + "location__name": "Site" + "role__name": "EVPN Leaf" + "device_type__model": "model name" + "interfaces": + - "!create_or_update:name": "Ethernet33/1" + "type": "100gbase-x-qsfp28" + "!ref": "leaf1_to_spine1" + "status__name": "Active" + - "!create_or_update:name": "Ethernet34/1" + "type": "100gbase-x-qsfp28" + "!ref": "leaf1_to_spine2" + "status__name": "Active" + - "!create_or_update:name": "Ethernet35/1" + "type": "100gbase-x-qsfp28" + "!ref": "leaf1_to_spine3" + "status__name": "Active" + - "!create_or_update:name": "leaf2" + "status__name": "Active" + "location__name": "Site" + "role__name": "EVPN Leaf" + "device_type__model": "model name" + "interfaces": + - "!create_or_update:name": "Ethernet33/1" + "type": "100gbase-x-qsfp28" + "!ref": "leaf2_to_spine1" + "status__name": "Active" + - "!create_or_update:name": "Ethernet34/1" + "type": "100gbase-x-qsfp28" + "!ref": "leaf2_to_spine2" + "status__name": "Active" + - "!create_or_update:name": "Ethernet35/1" + "type": "100gbase-x-qsfp28" + "!ref": "leaf2_to_spine3" + "status__name": "Active" + + cables: + - "!create_or_update:termination_a_id": "!ref:spine1_to_leaf1.id" + "!create_or_update:termination_b_id": "!ref:leaf1_to_spine1.id" + "termination_a": "!ref:spine1_to_leaf1" + "termination_b": "!ref:leaf1_to_spine1" + "status__name": "Planned" + - "!create_or_update:termination_a_id": "!ref:spine2_to_leaf1.id" + "!create_or_update:termination_b_id": "!ref:leaf1_to_spine2.id" + "termination_a": "!ref:spine2_to_leaf1" + "termination_b": "!ref:leaf1_to_spine2" + "status__name": "Planned" + - "!create_or_update:termination_a_id": "!ref:spine3_to_leaf1.id" + "!create_or_update:termination_b_id": "!ref:leaf1_to_spine3.id" + "termination_a": "!ref:spine3_to_leaf1" + "termination_b": "!ref:leaf1_to_spine3" + "status__name": "Planned" + - "!create_or_update:termination_a_id": "!ref:spine1_to_leaf2.id" + "!create_or_update:termination_b_id": "!ref:leaf2_to_spine1.id" + "termination_a": "!ref:spine1_to_leaf2" + "termination_b": "!ref:leaf2_to_spine1" + "status__name": "Planned" + - "!create_or_update:termination_a_id": "!ref:spine2_to_leaf2.id" + "!create_or_update:termination_b_id": "!ref:leaf2_to_spine2.id" + "termination_a": "!ref:spine2_to_leaf2" + "termination_b": "!ref:leaf2_to_spine2" + "status__name": "Planned" + - "!create_or_update:termination_a_id": "!ref:spine3_to_leaf2.id" + "!create_or_update:termination_b_id": "!ref:leaf2_to_spine3.id" + "termination_a": "!ref:spine3_to_leaf2" + "termination_b": "!ref:leaf2_to_spine3" + "status__name": "Planned" diff --git a/nautobot_design_builder/tests/testdata/create_or_update_by_ref.yaml b/nautobot_design_builder/tests/testdata/create_or_update_by_ref.yaml new file mode 100644 index 00000000..b7c0bc34 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/create_or_update_by_ref.yaml @@ -0,0 +1,63 @@ +--- +designs: + # Design 1 + - secrets: + - "!create_or_update:name": "Device username" + "description": "Username for network devices" + "provider": "environment-variable" + "parameters": {"variable": "NAUTOBOT_NAPALM_USERNAME"} + "!ref": "device_username" + - "!create_or_update:name": "Device password" + "description": "Password for network devices" + "provider": "environment-variable" + "parameters": {"variable": "NAUTOBOT_NAPALM_PASSWORD"} + "!ref": "device_password" + + secrets_groups: + - "!create_or_update:name": "Device credentials" + "!ref": "device_credentials" + + secrets_group_associations: + - "!create_or_update:secrets_group": "!ref:device_credentials" + "!create_or_update:secret": "!ref:device_username" + "access_type": "Generic" + "secret_type": "username" + - "!create_or_update:secrets_group": "!ref:device_credentials" + "!create_or_update:secret": "!ref:device_password" + "access_type": "Generic" + "secret_type": "password" + # Design 2 + - secrets: + - "!create_or_update:name": "Device username" + "description": "Username for network devices" + "provider": "environment-variable" + "parameters": {"variable": "NAUTOBOT_NAPALM_USERNAME"} + "!ref": "device_username" + - "!create_or_update:name": "Device password" + "description": "Password for network devices" + "provider": "environment-variable" + "parameters": {"variable": "NAUTOBOT_NAPALM_PASSWORD"} + "!ref": "device_password" + secrets_groups: + - "!create_or_update:name": "Device credentials" + "!ref": "device_credentials" + + secrets_group_associations: + - "!create_or_update:secrets_group": "!ref:device_credentials" + "!create_or_update:secret": "!ref:device_username" + "access_type": "Generic" + "secret_type": "username" + - "!create_or_update:secrets_group": "!ref:device_credentials" + "!create_or_update:secret": "!ref:device_password" + "access_type": "Generic" + "secret_type": "password" +checks: + - count: + model: "nautobot.extras.models.Secret" + count: 2 + - count: + model: "nautobot.extras.models.SecretsGroup" + count: 1 + - count: + model: "nautobot.extras.models.SecretsGroupAssociation" + count: 2 diff --git a/nautobot_design_builder/tests/testdata/create_or_update_mlag.yaml b/nautobot_design_builder/tests/testdata/create_or_update_mlag.yaml new file mode 100644 index 00000000..c225c560 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/create_or_update_mlag.yaml @@ -0,0 +1,41 @@ +--- +depends_on: "base_test.yaml" +designs: + - devices: + - name: "device_1" + location__name: "Site" + status__name: "Active" + device_type__model: "model name" + role__name: "device role" + interfaces: + - name: "Ethernet1/1" + type: "1000base-t" + status__name: "Active" + "!ref": "ethernet11" + - name: "Ethernet2/1" + type: "1000base-t" + status__name: "Active" + "!ref": "ethernet21" + - name: "Ethernet3/1" + type: "1000base-t" + status__name: "Active" + "!ref": "ethernet31" + - name: "Ethernet4/1" + type: "1000base-t" + status__name: "Active" + "!ref": "ethernet41" + - name: "Port-Channel1" + type: "lag" + status__name: "Active" + member_interfaces: + - "!ref:ethernet11" + - "!ref:ethernet21" + - "!ref:ethernet31" + - "!ref:ethernet41" +checks: + - equal: + - model: "nautobot.dcim.models.Interface" + query: {name: "Port-Channel1"} + attribute: "member_interfaces" + - model: "nautobot.dcim.models.Interface" + query: {name__startswith: "Ethernet"} diff --git a/nautobot_design_builder/tests/testdata/create_or_update_rack.yaml b/nautobot_design_builder/tests/testdata/create_or_update_rack.yaml new file mode 100644 index 00000000..aa8024a8 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/create_or_update_rack.yaml @@ -0,0 +1,19 @@ +--- +depends_on: "base_test.yaml" +designs: + - devices: + - "!create_or_update:name": "test device" + "!create_or_update:device_type__manufacturer__name": "manufacturer1" + role__name: "device role" + location__name: "Site" + status__name: "Active" + rack: + "!create_or_update:name": "rack-1" + "!create_or_update:location__name": "Site" + status__name: "Active" +checks: + - equal: + - model: "nautobot.dcim.models.Device" + query: {name: "test device"} + attribute: "rack.name" + - value: "rack-1" diff --git a/nautobot_design_builder/tests/testdata/create_or_update_relationships.yaml b/nautobot_design_builder/tests/testdata/create_or_update_relationships.yaml new file mode 100644 index 00000000..a4b910ec --- /dev/null +++ b/nautobot_design_builder/tests/testdata/create_or_update_relationships.yaml @@ -0,0 +1,21 @@ +--- +depends_on: "base_test.yaml" +designs: + - devices: + - "!create_or_update:name": "test device" + "!create_or_update:device_type__manufacturer__name": "manufacturer1" + "role__name": "device role" + "location__name": "Site" + "status__name": "Active" +checks: + - equal: + - model: "nautobot.dcim.models.DeviceType" + query: {model: "model name"} + attribute: "manufacturer.name" + - value: "manufacturer1" + - equal: + - model: "nautobot.dcim.models.DeviceType" + query: {model: "model name"} + - model: "nautobot.dcim.models.Device" + query: {name: "test device"} + attribute: "device_type" diff --git a/nautobot_design_builder/tests/testdata/create_tags.yaml b/nautobot_design_builder/tests/testdata/create_tags.yaml new file mode 100644 index 00000000..62ff3b94 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/create_tags.yaml @@ -0,0 +1,9 @@ +--- +designs: + - tags: + - name: "Test Tag" + description: "Some Description" +checks: + - model_exists: + model: "nautobot.extras.models.Tag" + query: {name: "Test Tag"} diff --git a/nautobot_design_builder/tests/testdata/custom_relationship_by_key.yaml b/nautobot_design_builder/tests/testdata/custom_relationship_by_key.yaml index 88a9bc11..fbfd2ddf 100644 --- a/nautobot_design_builder/tests/testdata/custom_relationship_by_key.yaml +++ b/nautobot_design_builder/tests/testdata/custom_relationship_by_key.yaml @@ -1,6 +1,7 @@ --- extensions: - "nautobot_design_builder.contrib.ext.LookupExtension" +depends_on: "base_test.yaml" designs: - relationships: - label: "Device to VLANS" @@ -13,29 +14,6 @@ designs: app_label: "ipam" model: "vlan" - manufacturers: - - name: "manufacturer1" - - device_types: - - manufacturer__name: "manufacturer1" - model: "model name" - u_height: 1 - - roles: - - name: "device role" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - - location_types: - - name: "Site" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - locations: - - name: "site_1" - status__name: "Active" - vlans: - "!create_or_update:vid": 42 name: "The Answer" @@ -43,7 +21,7 @@ designs: devices: - name: "device_1" - location__name: "site_1" + location__name: "Site" status__name: "Active" device_type__model: "model name" role__name: "device role" diff --git a/nautobot_design_builder/tests/testdata/custom_relationship_by_label.yaml b/nautobot_design_builder/tests/testdata/custom_relationship_by_label.yaml index c63782bc..0b8260f0 100644 --- a/nautobot_design_builder/tests/testdata/custom_relationship_by_label.yaml +++ b/nautobot_design_builder/tests/testdata/custom_relationship_by_label.yaml @@ -1,6 +1,7 @@ --- extensions: - "nautobot_design_builder.contrib.ext.LookupExtension" +depends_on: "base_test.yaml" designs: - relationships: - label: "Device to VLANS" @@ -13,29 +14,6 @@ designs: app_label: "ipam" model: "vlan" - manufacturers: - - name: "manufacturer1" - - device_types: - - manufacturer__name: "manufacturer1" - model: "model name" - u_height: 1 - - roles: - - name: "device role" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - - location_types: - - name: "Site" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - locations: - - name: "site_1" - status__name: "Active" - vlans: - "!create_or_update:vid": 42 name: "The Answer" @@ -43,7 +21,7 @@ designs: devices: - name: "device_1" - location__name: "site_1" + location__name: "Site" status__name: "Active" device_type__model: "model name" role__name: "device role" diff --git a/nautobot_design_builder/tests/testdata/device_primary_ip.yaml b/nautobot_design_builder/tests/testdata/device_primary_ip.yaml new file mode 100644 index 00000000..dfd4e22d --- /dev/null +++ b/nautobot_design_builder/tests/testdata/device_primary_ip.yaml @@ -0,0 +1,28 @@ +--- +depends_on: "base_test.yaml" +designs: + - prefixes: + - prefix: "192.168.56.0/24" + status__name: "Active" + devices: + - name: "device_1" + location__name: "Site" + status__name: "Active" + device_type__model: "model name" + role__name: "device role" + interfaces: + - name: "Ethernet1/1" + type: "virtual" + status__name: "Active" + description: "description for Ethernet1/1" + ip_address_assignments: + - ip_address: + address: "192.168.56.1/24" + status__name: "Active" + primary_ip4: {"!get:address": "192.168.56.1/24", "deferred": true} +checks: + - equal: + - model: "nautobot.dcim.models.Device" + query: {name: "device_1"} + attribute: "primary_ip4.address.__str__" + - value: "192.168.56.1/24" diff --git a/nautobot_design_builder/tests/testdata/interface_addresses.yaml b/nautobot_design_builder/tests/testdata/interface_addresses.yaml index 5f18e090..036381b5 100644 --- a/nautobot_design_builder/tests/testdata/interface_addresses.yaml +++ b/nautobot_design_builder/tests/testdata/interface_addresses.yaml @@ -1,36 +1,14 @@ --- +depends_on: "base_test.yaml" designs: - - manufacturers: - - name: "manufacturer1" - - device_types: - - manufacturer__name: "manufacturer1" - model: "model name" - u_height: 1 - - roles: - - name: "device role" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - - location_types: - - name: "Site" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - locations: - - name: "site_1" - status__name: "Active" - - prefixes: + - prefixes: - prefix: "192.168.56.0/24" status__name: "Active" "!ref": "parent_prefix" devices: - name: "device_1" - location__name: "site_1" + location__name: "Site" status__name: "Active" device_type__model: "model name" role__name: "device role" diff --git a/nautobot_design_builder/tests/testdata/many_to_many.yaml b/nautobot_design_builder/tests/testdata/many_to_many.yaml index 4691f14c..6bb00985 100644 --- a/nautobot_design_builder/tests/testdata/many_to_many.yaml +++ b/nautobot_design_builder/tests/testdata/many_to_many.yaml @@ -1,9 +1,7 @@ --- +depends_on: "base_test.yaml" designs: - - manufacturers: - - name: "manufacturer1" - - device_types: + - device_types: - manufacturer__name: "manufacturer1" model: "model1" "!ref": "model1" @@ -23,6 +21,7 @@ checks: - equal: - model: "nautobot.dcim.models.DeviceType" + query: {model__in: ["model1", "model2"]} - model: "nautobot.extras.models.ConfigContext" query: {name: "My Context"} attribute: "device_types" diff --git a/nautobot_design_builder/tests/testdata/nested_create.yaml b/nautobot_design_builder/tests/testdata/nested_create.yaml index 9122a160..f5122c04 100644 --- a/nautobot_design_builder/tests/testdata/nested_create.yaml +++ b/nautobot_design_builder/tests/testdata/nested_create.yaml @@ -1,31 +1,9 @@ --- +depends_on: "base_test.yaml" designs: - - manufacturers: - - name: "manufacturer1" - - device_types: - - manufacturer__name: "manufacturer1" - model: "model name" - u_height: 1 - - roles: - - name: "device role" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - - location_types: - - name: "Site" - content_types: - - "!get:app_label": "dcim" - "!get:model": "device" - locations: - - name: "site_1" - status__name: "Active" - - devices: + - devices: - name: "device_1" - location__name: "site_1" + location__name: "Site" status__name: "Active" device_type__model: "model name" role__name: "device role" @@ -46,4 +24,4 @@ checks: query: {name: "device_1"} attribute: "location" - model: "nautobot.dcim.models.Location" - query: {name: "site_1"} + query: {name: "Site"} diff --git a/nautobot_design_builder/tests/testdata/nested_update.yaml b/nautobot_design_builder/tests/testdata/nested_update.yaml new file mode 100644 index 00000000..d98b3a2f --- /dev/null +++ b/nautobot_design_builder/tests/testdata/nested_update.yaml @@ -0,0 +1,33 @@ +--- +depends_on: "base_test.yaml" +designs: + # Design 1 + - devices: + - name: "device_1" + location__name: "Site" + status__name: "Active" + device_type__model: "model name" + role__name: "device role" + interfaces: + - name: "Ethernet1/1" + type: "virtual" + status__name: "Active" + description: "description for Ethernet1/1" + # Design 2 + - devices: + - "!update:name": "device_1" + interfaces: + - "!update:name": "Ethernet1/1" + description: "new description for Ethernet1/1" +checks: + - equal: + - model: "nautobot.dcim.models.Interface" + query: {name: "Ethernet1/1"} + attribute: "device" + - model: "nautobot.dcim.models.Device" + query: {name: "device_1"} + - equal: + - model: "nautobot.dcim.models.Interface" + query: {name: "Ethernet1/1"} + attribute: "description" + - value: "new description for Ethernet1/1" diff --git a/nautobot_design_builder/tests/testdata/one_to_one.yaml b/nautobot_design_builder/tests/testdata/one_to_one.yaml new file mode 100644 index 00000000..79d038c1 --- /dev/null +++ b/nautobot_design_builder/tests/testdata/one_to_one.yaml @@ -0,0 +1,41 @@ +--- +depends_on: "base_test.yaml" +designs: + - device_types: + - manufacturer__name: "manufacturer1" + model: "chassis" + u_height: 1 + subdevice_role: "parent" + + - manufacturer__name: "manufacturer1" + model: "card" + u_height: 0 + subdevice_role: "child" + + devices: + - name: "device_1" + location__name: "Site" + status__name: "Active" + device_type__model: "chassis" + role__name: "device role" + device_bays: + - name: "Bay 1" + installed_device: + name: "device_2" + location__name: "Site" + status__name: "Active" + device_type__model: "card" + role__name: "device role" +checks: + - model_exists: + model: "nautobot.dcim.models.Device" + query: {name: "device_1"} + - model_exists: + model: "nautobot.dcim.models.Device" + query: {name: "device_2"} + - equal: + - model: "nautobot.dcim.models.Device" + query: {name: "device_2"} + attribute: "parent_bay.device" + - model: "nautobot.dcim.models.Device" + query: {name: "device_1"} diff --git a/nautobot_design_builder/tests/testdata/prefixes.yaml b/nautobot_design_builder/tests/testdata/prefixes.yaml index d6d18c19..e40eb0ee 100644 --- a/nautobot_design_builder/tests/testdata/prefixes.yaml +++ b/nautobot_design_builder/tests/testdata/prefixes.yaml @@ -1,28 +1,21 @@ --- +depends_on: "base_test.yaml" designs: - - location_types: - - name: "Site" - content_types: - - "!get:app_label": "ipam" - "!get:model": "prefix" - locations: - - name: "site_1" - status__name: "Active" - "!ref": "site_1" - - prefixes: + - prefixes: - locations: - - "!ref:site_1" + - location: + "!get:name": "Site" status__name: "Active" prefix: "192.168.0.0/24" - "!create_or_update:prefix": "192.168.56.0/24" locations: - - "!ref:site_1" + - location: + "!get:name": "Site" status__name: "Active" checks: - equal: - model: "nautobot.ipam.models.Prefix" - query: {locations__name: "site_1"} + query: {locations__name: "Site"} attribute: "__str__" - value: ["192.168.0.0/24", "192.168.56.0/24"] From 0313c8a88048e7eb20d57b83d24fa6ebf66a5525 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 11:44:28 -0400 Subject: [PATCH 2/9] Added branch arg to install_demo_designs mgmt cmd --- .../management/commands/install_demo_designs.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nautobot_design_builder/management/commands/install_demo_designs.py b/nautobot_design_builder/management/commands/install_demo_designs.py index b7f4f7c9..36cccd76 100644 --- a/nautobot_design_builder/management/commands/install_demo_designs.py +++ b/nautobot_design_builder/management/commands/install_demo_designs.py @@ -8,13 +8,21 @@ class Command(BaseCommand): """Create a git datasource pointed to the demo designs repo.""" + def add_arguments(self, parser): + parser.add_argument( + "--branch", + action="store", + help="Specify which branch to use in the demo-design repository (default: main).", + default="main" + ) + def handle(self, *args, **options): """Handle the execution of the command.""" GitRepository.objects.get_or_create( name="Demo Designs", defaults={ "remote_url": "https://github.com/nautobot/demo-designs.git", - "branch": "main", + "branch": options["branch"], "provided_contents": ["extras.job"], }, ) From 55c5ce5819a02bf180047b537a81ee13ad6e08b1 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 11:45:18 -0400 Subject: [PATCH 3/9] Fixed many-to-many through --- nautobot_design_builder/fields.py | 58 ++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/nautobot_design_builder/fields.py b/nautobot_design_builder/fields.py index eb9c9894..ebc2163b 100644 --- a/nautobot_design_builder/fields.py +++ b/nautobot_design_builder/fields.py @@ -213,10 +213,18 @@ class ManyToManyField(BaseModelField, RelationshipFieldMixin): # pylint:disable def __init__(self, field: django_models.Field): # noqa:D102 super().__init__(field) - if hasattr(field.remote_field, "through"): - through = field.remote_field.through - if not through._meta.auto_created: - self.related_model = through + self.auto_through = True + self.through_fields = field.remote_field.through_fields + through = field.remote_field.through + if not through._meta.auto_created: + self.auto_through = False + self.related_model = through + if field.remote_field.through_fields: + self.link_field = field.remote_field.through_fields[0] + else: + for f in through._meta.fields: + if f.related_model == field.model: + self.link_field = f.name @debug_set def __set__(self, obj: "ModelInstance", values): # noqa:D105 @@ -224,14 +232,30 @@ def setter(): items = [] for value in values: value = self._get_instance(obj, value, getattr(obj.instance, self.field_name)) + if self.auto_through: + # Only need to call `add` if the through relationship was + # auto-created. Otherwise we explicitly create the through + # object + items.append(value.instance) + else: + setattr(value.instance, self.link_field, obj.instance) if value.metadata.created: value.save() - items.append(value.instance) - getattr(obj.instance, self.field_name).add(*items) + else: + value.environment.journal.log(value) + if items: + getattr(obj.instance, self.field_name).add(*items) obj.connect("POST_INSTANCE_SAVE", setter) +class ManyToManyRelField(ManyToManyField): # pylint:disable=too-few-public-methods + """Reverse many to many relationship field.""" + + def __init__(self, field: django_models.Field): # noqa:D102 + super().__init__(field.remote_field) + + class GenericRelationField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods """Generic relationship field.""" @@ -259,13 +283,29 @@ def __set__(self, obj: "ModelInstance", value): # noqa:D105 setattr(obj.instance, ct_field, ContentType.objects.get_for_model(value.instance)) -class TagField(ManyToManyField): # pylint:disable=too-few-public-methods +class TagField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods """Taggit field.""" def __init__(self, field: django_models.Field): # noqa:D102 super().__init__(field) self.related_model = field.remote_field.model + def __set__(self, obj: "ModelInstance", values): # noqa:D105 + # I hate that this code is almost identical to the ManyToManyField + # __set__ code, but I don't see an easy way to DRY it up at the + # moment. + def setter(): + items = [] + for value in values: + value = self._get_instance(obj, value, getattr(obj.instance, self.field_name)) + if value.metadata.created: + value.save() + else: + value.environment.journal.log(value) + items.append(value.instance) + getattr(obj.instance, self.field_name).add(*items) + + obj.connect("POST_INSTANCE_SAVE", setter) class GenericRelField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods """Field used as part of content-types generic relation.""" @@ -348,8 +388,10 @@ def field_factory(arg1, arg2) -> ModelField: field = ForeignKeyField(arg2) elif isinstance(arg2, django_models.ManyToOneRel): field = ManyToOneRelField(arg2) - elif isinstance(arg2, (django_models.ManyToManyField, django_models.ManyToManyRel)): + elif isinstance(arg2, django_models.ManyToManyField): field = ManyToManyField(arg2) + elif isinstance(arg2, django_models.ManyToManyRel): + field = ManyToManyRelField(arg2) else: raise DesignImplementationError(f"Cannot manufacture field for {type(arg2)}, {arg2} {arg2.is_relation}") return field From 5d08ad7a7cbdbde77e90754d727c35129a2d2b34 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 12:02:53 -0400 Subject: [PATCH 4/9] Auto-formatting --- nautobot_design_builder/fields.py | 1 + .../management/commands/install_demo_designs.py | 2 +- nautobot_design_builder/tests/test_builder.py | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/nautobot_design_builder/fields.py b/nautobot_design_builder/fields.py index ebc2163b..c9b59713 100644 --- a/nautobot_design_builder/fields.py +++ b/nautobot_design_builder/fields.py @@ -307,6 +307,7 @@ def setter(): obj.connect("POST_INSTANCE_SAVE", setter) + class GenericRelField(BaseModelField, RelationshipFieldMixin): # pylint:disable=too-few-public-methods """Field used as part of content-types generic relation.""" diff --git a/nautobot_design_builder/management/commands/install_demo_designs.py b/nautobot_design_builder/management/commands/install_demo_designs.py index 36cccd76..7adb3521 100644 --- a/nautobot_design_builder/management/commands/install_demo_designs.py +++ b/nautobot_design_builder/management/commands/install_demo_designs.py @@ -13,7 +13,7 @@ def add_arguments(self, parser): "--branch", action="store", help="Specify which branch to use in the demo-design repository (default: main).", - default="main" + default="main", ) def handle(self, *args, **options): diff --git a/nautobot_design_builder/tests/test_builder.py b/nautobot_design_builder/tests/test_builder.py index d5141843..cf23a43b 100644 --- a/nautobot_design_builder/tests/test_builder.py +++ b/nautobot_design_builder/tests/test_builder.py @@ -137,6 +137,7 @@ def _run_test_case(self, testcase, data_dir): getattr(BuilderChecks, _check_name)(self, args, index) else: raise ValueError(f"Unknown check {check_name} {check}") + setattr(test_class, "_run_test_case", _run_test_case) for testcase, filename in _testcases(data_dir): @@ -149,6 +150,7 @@ def test_runner(self): if testcase.get("skip", False): self.skipTest("Skipping due to testcase skip=true") self._run_test_case(testcase, data_dir) + return test_runner setattr(test_class, testcase_name, test_wrapper(testcase)) From 8e0ad05d3b759d42fa68a41de905d94d76c65daa Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 13:20:28 -0400 Subject: [PATCH 5/9] Status content-types test --- nautobot_design_builder/tests/test_builder.py | 39 +++++++++++++++---- .../tests/testdata/base_test.yaml | 2 +- .../testdata/update_status_content_types.yaml | 22 +++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 nautobot_design_builder/tests/testdata/update_status_content_types.yaml diff --git a/nautobot_design_builder/tests/test_builder.py b/nautobot_design_builder/tests/test_builder.py index cf23a43b..2a088e80 100644 --- a/nautobot_design_builder/tests/test_builder.py +++ b/nautobot_design_builder/tests/test_builder.py @@ -64,6 +64,23 @@ def check_model_not_exist(test, check, index): values = _get_value(check) test.assertEqual(len(values), 0, msg=f"Check {index}") + @staticmethod + def check_in(test, check, index): + """Check that a model does not exist.""" + value0 = _get_value(check[0])[0] + value1 = _get_value(check[1]) + if len(value1) == 1: + value1 = value1[0] + test.assertIn(value0, value1, msg=f"Check {index}") + + @staticmethod + def check_not_in(test, check, index): + """Check that a model does not exist.""" + value0 = _get_value(check[0])[0] + value1 = _get_value(check[1]) + if len(value1) == 1: + value1 = value1[0] + test.assertNotIn(value0, value1, msg=f"Check {index}") def _get_value(check_info): if "value" in check_info: @@ -109,8 +126,20 @@ def builder_test_case(data_dir): """Decorator to load tests into a TestCase from a data directory.""" def class_wrapper(test_class): + def _run_checks(self, checks): + for index, check in enumerate(checks): + for check_name, args in check.items(): + _check_name = f"check_{check_name}" + if hasattr(BuilderChecks, _check_name): + getattr(BuilderChecks, _check_name)(self, args, index) + else: + raise ValueError(f"Unknown check {check_name} {check}") + setattr(test_class, "_run_checks", _run_checks) + def _run_test_case(self, testcase, data_dir): with patch("nautobot_design_builder.design.Environment.roll_back") as roll_back: + self._run_checks(testcase.get("pre_checks", [])) + depends_on = testcase.pop("depends_on", None) if depends_on: depends_on_path = os.path.join(data_dir, depends_on) @@ -130,17 +159,13 @@ def _run_test_case(self, testcase, data_dir): if not commit: roll_back.assert_called() - for index, check in enumerate(testcase.get("checks", [])): - for check_name, args in check.items(): - _check_name = f"check_{check_name}" - if hasattr(BuilderChecks, _check_name): - getattr(BuilderChecks, _check_name)(self, args, index) - else: - raise ValueError(f"Unknown check {check_name} {check}") + self._run_checks(testcase.get("checks", [])) setattr(test_class, "_run_test_case", _run_test_case) for testcase, filename in _testcases(data_dir): + if testcase.get("abstract", False): + continue # Strip the .yaml extension testcase_name = f"test_{filename[:-5]}" diff --git a/nautobot_design_builder/tests/testdata/base_test.yaml b/nautobot_design_builder/tests/testdata/base_test.yaml index 578af6a4..437712aa 100644 --- a/nautobot_design_builder/tests/testdata/base_test.yaml +++ b/nautobot_design_builder/tests/testdata/base_test.yaml @@ -1,5 +1,5 @@ --- -skip: true +abstract: true designs: - manufacturers: - name: "manufacturer1" diff --git a/nautobot_design_builder/tests/testdata/update_status_content_types.yaml b/nautobot_design_builder/tests/testdata/update_status_content_types.yaml new file mode 100644 index 00000000..b2ec4c7c --- /dev/null +++ b/nautobot_design_builder/tests/testdata/update_status_content_types.yaml @@ -0,0 +1,22 @@ +--- +designs: + - statuses: + - "!create_or_update:name": "Active" + content_types: + - "!get:model": "cable" + "!get:app_label": "dcim" + +pre_checks: + - not_in: + - model: "django.contrib.contenttypes.models.ContentType" + query: {app_label: "dcim", model: "cable"} + - model: "nautobot.extras.models.Status" + query: {name: "Active"} + attribute: "content_types" +checks: + - in: + - model: "django.contrib.contenttypes.models.ContentType" + query: {app_label: "dcim", model: "cable"} + - model: "nautobot.extras.models.Status" + query: {name: "Active"} + attribute: "content_types" From 57afae955e29cae7bd507d9288b303dc72fac3a2 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 13:24:48 -0400 Subject: [PATCH 6/9] Autoformatting --- nautobot_design_builder/tests/test_builder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nautobot_design_builder/tests/test_builder.py b/nautobot_design_builder/tests/test_builder.py index 2a088e80..1e315c4b 100644 --- a/nautobot_design_builder/tests/test_builder.py +++ b/nautobot_design_builder/tests/test_builder.py @@ -82,6 +82,7 @@ def check_not_in(test, check, index): value1 = value1[0] test.assertNotIn(value0, value1, msg=f"Check {index}") + def _get_value(check_info): if "value" in check_info: value = check_info["value"] @@ -134,6 +135,7 @@ def _run_checks(self, checks): getattr(BuilderChecks, _check_name)(self, args, index) else: raise ValueError(f"Unknown check {check_name} {check}") + setattr(test_class, "_run_checks", _run_checks) def _run_test_case(self, testcase, data_dir): From 4fcbb738cd46a02ae215f95bad259d5594206bd6 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 13:54:10 -0400 Subject: [PATCH 7/9] docs: Documented `add_arguments` method --- .../management/commands/install_demo_designs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nautobot_design_builder/management/commands/install_demo_designs.py b/nautobot_design_builder/management/commands/install_demo_designs.py index 7adb3521..c8fbf876 100644 --- a/nautobot_design_builder/management/commands/install_demo_designs.py +++ b/nautobot_design_builder/management/commands/install_demo_designs.py @@ -9,6 +9,7 @@ class Command(BaseCommand): """Create a git datasource pointed to the demo designs repo.""" def add_arguments(self, parser): + """Add the branch argument to the command.""" parser.add_argument( "--branch", action="store", From fd39cef205be1a25c4c9e7a45d995dd459a5fbae Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Thu, 9 May 2024 13:56:59 -0400 Subject: [PATCH 8/9] Whitespace --- .../tests/testdata/update_status_content_types.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nautobot_design_builder/tests/testdata/update_status_content_types.yaml b/nautobot_design_builder/tests/testdata/update_status_content_types.yaml index b2ec4c7c..4bcd84e8 100644 --- a/nautobot_design_builder/tests/testdata/update_status_content_types.yaml +++ b/nautobot_design_builder/tests/testdata/update_status_content_types.yaml @@ -1,10 +1,10 @@ --- designs: - statuses: - - "!create_or_update:name": "Active" - content_types: - - "!get:model": "cable" - "!get:app_label": "dcim" + - "!create_or_update:name": "Active" + content_types: + - "!get:model": "cable" + "!get:app_label": "dcim" pre_checks: - not_in: From 432460e2cdec1c22c726dccba66319f5c300a9b9 Mon Sep 17 00:00:00 2001 From: Andrew Bates Date: Mon, 13 May 2024 08:50:20 -0400 Subject: [PATCH 9/9] refactor: Refactored builder_test_case decorator The builder test case is now a base class instead of a decorator. This cleans up the layout of the code a bit making it easier to follow the logic. --- .../contrib/tests/test_ext.py | 9 +- nautobot_design_builder/tests/test_builder.py | 96 +++++++++---------- 2 files changed, 52 insertions(+), 53 deletions(-) diff --git a/nautobot_design_builder/contrib/tests/test_ext.py b/nautobot_design_builder/contrib/tests/test_ext.py index b18c52ba..da0de10e 100644 --- a/nautobot_design_builder/contrib/tests/test_ext.py +++ b/nautobot_design_builder/contrib/tests/test_ext.py @@ -2,11 +2,10 @@ import os -from django.test import TestCase +from nautobot_design_builder.tests.test_builder import BuilderTestCase -from nautobot_design_builder.tests.test_builder import builder_test_case - -@builder_test_case(os.path.join(os.path.dirname(__file__), "testdata")) -class TestAgnosticExtensions(TestCase): +class TestAgnosticExtensions(BuilderTestCase): """Test contrib extensions against any version of Nautobot.""" + + data_dir = os.path.join(os.path.dirname(__file__), "testdata") diff --git a/nautobot_design_builder/tests/test_builder.py b/nautobot_design_builder/tests/test_builder.py index 1e315c4b..b979715f 100644 --- a/nautobot_design_builder/tests/test_builder.py +++ b/nautobot_design_builder/tests/test_builder.py @@ -123,47 +123,12 @@ def _testcases(data_dir): yield yaml.safe_load(file), filename -def builder_test_case(data_dir): - """Decorator to load tests into a TestCase from a data directory.""" - - def class_wrapper(test_class): - def _run_checks(self, checks): - for index, check in enumerate(checks): - for check_name, args in check.items(): - _check_name = f"check_{check_name}" - if hasattr(BuilderChecks, _check_name): - getattr(BuilderChecks, _check_name)(self, args, index) - else: - raise ValueError(f"Unknown check {check_name} {check}") - - setattr(test_class, "_run_checks", _run_checks) - - def _run_test_case(self, testcase, data_dir): - with patch("nautobot_design_builder.design.Environment.roll_back") as roll_back: - self._run_checks(testcase.get("pre_checks", [])) - - depends_on = testcase.pop("depends_on", None) - if depends_on: - depends_on_path = os.path.join(data_dir, depends_on) - depends_on_dir = os.path.dirname(depends_on_path) - with open(depends_on_path, encoding="utf-8") as file: - self._run_test_case(yaml.safe_load(file), depends_on_dir) - - extensions = [] - for extension in testcase.get("extensions", []): - extensions.append(_load_class(extension)) - - with self.captureOnCommitCallbacks(execute=True): - for design in testcase["designs"]: - environment = Environment(extensions=extensions) - commit = design.pop("commit", True) - environment.implement_design(design=design, commit=commit) - if not commit: - roll_back.assert_called() - - self._run_checks(testcase.get("checks", [])) - - setattr(test_class, "_run_test_case", _run_test_case) +class _BuilderTestCaseMeta(type): + def __new__(mcs, name, bases, dct): + cls = super().__new__(mcs, name, bases, dct) + data_dir = getattr(cls, "data_dir", None) + if data_dir is None: + return cls for testcase, filename in _testcases(data_dir): if testcase.get("abstract", False): @@ -173,19 +138,54 @@ def _run_test_case(self, testcase, data_dir): # Create a new closure for testcase def test_wrapper(testcase): - def test_runner(self): + def test_runner(self: "BuilderTestCase"): if testcase.get("skip", False): self.skipTest("Skipping due to testcase skip=true") - self._run_test_case(testcase, data_dir) + self._run_test_case(testcase, cls.data_dir) # pylint:disable=protected-access return test_runner - setattr(test_class, testcase_name, test_wrapper(testcase)) - return test_class + setattr(cls, testcase_name, test_wrapper(testcase)) + return cls - return class_wrapper +class BuilderTestCase(TestCase, metaclass=_BuilderTestCaseMeta): # pylint:disable=missing-class-docstring + def _run_checks(self, checks): + for index, check in enumerate(checks): + for check_name, args in check.items(): + _check_name = f"check_{check_name}" + if hasattr(BuilderChecks, _check_name): + getattr(BuilderChecks, _check_name)(self, args, index) + else: + raise ValueError(f"Unknown check {check_name} {check}") + + def _run_test_case(self, testcase, data_dir): + with patch("nautobot_design_builder.design.Environment.roll_back") as roll_back: + self._run_checks(testcase.get("pre_checks", [])) + + depends_on = testcase.pop("depends_on", None) + if depends_on: + depends_on_path = os.path.join(data_dir, depends_on) + depends_on_dir = os.path.dirname(depends_on_path) + with open(depends_on_path, encoding="utf-8") as file: + self._run_test_case(yaml.safe_load(file), depends_on_dir) + + extensions = [] + for extension in testcase.get("extensions", []): + extensions.append(_load_class(extension)) -@builder_test_case(os.path.join(os.path.dirname(__file__), "testdata")) -class TestGeneralDesigns(TestCase): + with self.captureOnCommitCallbacks(execute=True): + for design in testcase["designs"]: + environment = Environment(extensions=extensions) + commit = design.pop("commit", True) + environment.implement_design(design=design, commit=commit) + if not commit: + roll_back.assert_called() + + self._run_checks(testcase.get("checks", [])) + + +class TestGeneralDesigns(BuilderTestCase): """Designs that should work with all versions of Nautobot.""" + + data_dir = os.path.join(os.path.dirname(__file__), "testdata")