From fff9b7630b14f7a05813e7f0cf4cd180d728b558 Mon Sep 17 00:00:00 2001 From: Stephen Kiely Date: Wed, 8 Jan 2025 09:52:44 -0600 Subject: [PATCH] Back out some of the drift manager changes. --- nautobot_golden_config/api/serializers.py | 122 +- nautobot_golden_config/api/urls.py | 27 +- nautobot_golden_config/api/views.py | 363 ++++- nautobot_golden_config/filters.py | 447 +++++- nautobot_golden_config/forms.py | 619 +++++++- nautobot_golden_config/models.py | 844 +++++++++- nautobot_golden_config/navigation.py | 156 +- nautobot_golden_config/tables.py | 528 ++++++- .../compliancefeature_retrieve.html | 33 +- nautobot_golden_config/tests/fixtures.py | 10 - .../tests/test_api_views.py | 27 - .../tests/test_filter_compliancefeature.py | 28 - .../tests/test_form_compliancefeature.py | 33 - .../tests/test_model_compliancefeature.py | 22 - nautobot_golden_config/tests/test_views.py | 405 ++++- nautobot_golden_config/urls.py | 16 +- nautobot_golden_config/views.py | 597 ++++++- poetry.lock | 1389 ++++++++++++++++- 18 files changed, 5398 insertions(+), 268 deletions(-) delete mode 100644 nautobot_golden_config/tests/fixtures.py delete mode 100644 nautobot_golden_config/tests/test_api_views.py delete mode 100644 nautobot_golden_config/tests/test_filter_compliancefeature.py delete mode 100644 nautobot_golden_config/tests/test_form_compliancefeature.py delete mode 100644 nautobot_golden_config/tests/test_model_compliancefeature.py diff --git a/nautobot_golden_config/api/serializers.py b/nautobot_golden_config/api/serializers.py index f6b88ddc..1d5aaf2b 100644 --- a/nautobot_golden_config/api/serializers.py +++ b/nautobot_golden_config/api/serializers.py @@ -1,11 +1,22 @@ """API serializers for nautobot_golden_config.""" +# pylint: disable=too-many-ancestors from nautobot.apps.api import NautobotModelSerializer, TaggedModelSerializerMixin +from nautobot.dcim.api.serializers import DeviceSerializer +from nautobot.dcim.models import Device +from rest_framework import serializers from nautobot_golden_config import models +from nautobot_golden_config.utilities.config_postprocessing import get_config_postprocessing -class ComplianceFeatureSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): # pylint: disable=too-many-ancestors +class GraphQLSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for a GraphQL object.""" + + data = serializers.JSONField() + + +class ComplianceFeatureSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): """ComplianceFeature Serializer.""" class Meta: @@ -16,3 +27,112 @@ class Meta: # Option for disabling write for certain fields: # read_only_fields = [] + + +class ComplianceRuleSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for ComplianceRule object.""" + + class Meta: + """Set Meta Data for ComplianceRule, will serialize all fields.""" + + model = models.ComplianceRule + fields = "__all__" + + +class ConfigComplianceSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for ConfigCompliance object.""" + + class Meta: + """Set Meta Data for ConfigCompliance, will serialize fields.""" + + model = models.ConfigCompliance + fields = "__all__" + + +class GoldenConfigSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for GoldenConfig object.""" + + class Meta: + """Set Meta Data for GoldenConfig, will serialize all fields.""" + + model = models.GoldenConfig + fields = "__all__" + + +class GoldenConfigSettingSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for GoldenConfigSetting object.""" + + class Meta: + """Set Meta Data for GoldenConfigSetting, will serialize all fields.""" + + model = models.GoldenConfigSetting + fields = "__all__" + + +class ConfigRemoveSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for ConfigRemove object.""" + + class Meta: + """Set Meta Data for ConfigRemove, will serialize all fields.""" + + model = models.ConfigRemove + fields = "__all__" + + +class ConfigReplaceSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for ConfigReplace object.""" + + class Meta: + """Set Meta Data for ConfigReplace, will serialize all fields.""" + + model = models.ConfigReplace + fields = "__all__" + + +class ConfigToPushSerializer(DeviceSerializer): # pylint: disable=nb-sub-class-name + """Serializer for ConfigToPush view.""" + + config = serializers.SerializerMethodField() + + class Meta(DeviceSerializer.Meta): + """Extend the Device serializer with the configuration after postprocessing.""" + + fields = "__all__" + model = Device + + def get_config(self, obj): + """Provide the intended configuration ready after postprocessing to the config field.""" + request = self.context.get("request") + config_details = models.GoldenConfig.objects.get(device=obj) + return get_config_postprocessing(config_details, request) + + +class RemediationSettingSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for RemediationSetting object.""" + + class Meta: + """Set Meta Data for RemediationSetting, will serialize all fields.""" + + model = models.RemediationSetting + fields = "__all__" + + +class ConfigPlanSerializer(NautobotModelSerializer, TaggedModelSerializerMixin): + """Serializer for ConfigPlan object.""" + + class Meta: + """Set Meta Data for ConfigPlan, will serialize all fields.""" + + model = models.ConfigPlan + fields = "__all__" + read_only_fields = ["device", "plan_type", "feature", "config_set"] + + +class GenerateIntendedConfigSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for GenerateIntendedConfigView.""" + + intended_config = serializers.CharField(read_only=True) + intended_config_lines = serializers.ListField(read_only=True, child=serializers.CharField()) + graphql_data = serializers.JSONField(read_only=True) + diff = serializers.CharField(read_only=True) + diff_lines = serializers.ListField(read_only=True, child=serializers.CharField()) diff --git a/nautobot_golden_config/api/urls.py b/nautobot_golden_config/api/urls.py index 6ba20c08..8c4652af 100644 --- a/nautobot_golden_config/api/urls.py +++ b/nautobot_golden_config/api/urls.py @@ -1,11 +1,34 @@ """Django API urlpatterns declaration for nautobot_golden_config app.""" +from django.urls import path from nautobot.apps.api import OrderedDefaultRouter from nautobot_golden_config.api import views router = OrderedDefaultRouter() # add the name of your api endpoint, usually hyphenated model name in plural, e.g. "my-model-classes" -router.register("compliancefeature", views.ComplianceFeatureViewSet) +router.APIRootView = views.GoldenConfigRootView +router.register("compliance-feature", views.ComplianceFeatureViewSet) +router.register("compliance-rule", views.ComplianceRuleViewSet) +router.register("config-compliance", views.ConfigComplianceViewSet) +router.register("golden-config", views.GoldenConfigViewSet) +router.register("golden-config-settings", views.GoldenConfigSettingViewSet) +router.register("config-remove", views.ConfigRemoveViewSet) +router.register("config-replace", views.ConfigReplaceViewSet) +router.register("remediation-setting", views.RemediationSettingViewSet) +router.register("config-postprocessing", views.ConfigToPushViewSet) +router.register("config-plan", views.ConfigPlanViewSet) -urlpatterns = router.urls +urlpatterns = [ + path( + "sotagg//", + views.SOTAggDeviceDetailView.as_view(), + name="device_detail", + ), + path( + "generate-intended-config/", + views.GenerateIntendedConfigView.as_view(), + name="generate_intended_config", + ), +] +urlpatterns += router.urls diff --git a/nautobot_golden_config/api/views.py b/nautobot_golden_config/api/views.py index 225b061e..01b10380 100644 --- a/nautobot_golden_config/api/views.py +++ b/nautobot_golden_config/api/views.py @@ -1,13 +1,78 @@ """API views for nautobot_golden_config.""" -from nautobot.apps.api import NautobotModelViewSet +import datetime +import difflib +import json +import logging +from pathlib import Path + +from django.contrib.contenttypes.models import ContentType +from django.utils.timezone import make_aware +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from jinja2.exceptions import TemplateError, TemplateSyntaxError +from nautobot.apps.api import NautobotModelViewSet, NotesViewSetMixin +from nautobot.apps.utils import render_jinja2 +from nautobot.core.api.views import ( + BulkDestroyModelMixin, + BulkUpdateModelMixin, + ModelViewSetMixin, + NautobotAPIVersionMixin, +) +from nautobot.dcim.models import Device +from nautobot.extras.datasources.git import ensure_git_repository +from nautobot.extras.models import GraphQLQuery +from nautobot_plugin_nornir.constants import NORNIR_SETTINGS +from nornir import InitNornir +from nornir_nautobot.plugins.tasks.dispatcher import dispatcher +from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import APIException +from rest_framework.generics import GenericAPIView +from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated +from rest_framework.response import Response +from rest_framework.routers import APIRootView +from rest_framework.views import APIView +from rest_framework.viewsets import GenericViewSet from nautobot_golden_config import filters, models from nautobot_golden_config.api import serializers +from nautobot_golden_config.utilities.graphql import graph_ql_query +from nautobot_golden_config.utilities.helper import dispatch_params, get_device_to_settings_map, get_django_env + + +class GoldenConfigRootView(APIRootView): + """Golden Config API root view.""" + + def get_view_name(self): + """Golden Config API root view boilerplate.""" + return "Golden Config" + + +class SOTAggDeviceDetailView(APIView): + """Detail REST API view showing graphql, with a potential "transformer" of data on a specific device.""" + + permission_classes = [AllowAny] + + def get(self, request, *args, **kwargs): + """Get method serialize for a dictionary to json response.""" + device = Device.objects.get(pk=kwargs["pk"]) + settings = get_device_to_settings_map(queryset=Device.objects.filter(pk=device.pk))[device.id] + status_code, data = graph_ql_query(request, device, settings.sot_agg_query.query) + data = json.loads(json.dumps(data)) + return Response(serializers.GraphQLSerializer(data=data).initial_data, status=status_code) + +class ComplianceRuleViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with ComplianceRule objects.""" -class ComplianceFeatureViewSet(NautobotModelViewSet): # pylint: disable=too-many-ancestors - """ComplianceFeature viewset.""" + queryset = models.ComplianceRule.objects.all() + serializer_class = serializers.ComplianceRuleSerializer + filterset_class = filters.ComplianceRuleFilterSet + + +class ComplianceFeatureViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with ComplianceFeature objects.""" queryset = models.ComplianceFeature.objects.all() serializer_class = serializers.ComplianceFeatureSerializer @@ -15,3 +80,295 @@ class ComplianceFeatureViewSet(NautobotModelViewSet): # pylint: disable=too-man # Option for modifying the default HTTP methods: # http_method_names = ["get", "post", "put", "patch", "delete", "head", "options", "trace"] + + +class ConfigComplianceViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with ConfigCompliance objects.""" + + queryset = models.ConfigCompliance.objects.all() + serializer_class = serializers.ConfigComplianceSerializer + filterset_class = filters.ConfigComplianceFilterSet + + +class GoldenConfigViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with GoldenConfig objects.""" + + queryset = models.GoldenConfig.objects.all() + serializer_class = serializers.GoldenConfigSerializer + filterset_class = filters.GoldenConfigFilterSet + + +class GoldenConfigSettingViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with GoldenConfigSetting objects.""" + + queryset = models.GoldenConfigSetting.objects.all() + serializer_class = serializers.GoldenConfigSettingSerializer + filterset_class = filters.GoldenConfigSettingFilterSet + + +class ConfigRemoveViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with ConfigRemove objects.""" + + queryset = models.ConfigRemove.objects.all() + serializer_class = serializers.ConfigRemoveSerializer + filterset_class = filters.ConfigRemoveFilterSet + + +class ConfigReplaceViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with ConfigReplace objects.""" + + queryset = models.ConfigReplace.objects.all() + serializer_class = serializers.ConfigReplaceSerializer + filterset_class = filters.ConfigReplaceFilterSet + + +class ConfigPushPermissions(BasePermission): + """Permissions class to validate access to Devices and GoldenConfig view.""" + + def has_permission(self, request, view): + """Method to validated permissions to API view.""" + return request.user.has_perm("nautobot_golden_config.view_goldenconfig") + + def has_object_permission(self, request, view, obj): + """Validate user access to the object, taking into account constraints.""" + return request.user.has_perm("dcim.view_device", obj=obj) + + +class ConfigToPushViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + """Detail REST API view showing configuration after postprocessing.""" + + permission_classes = [IsAuthenticated & ConfigPushPermissions] + queryset = Device.objects.all() + serializer_class = serializers.ConfigToPushSerializer + + +class RemediationSettingViewSet(NautobotModelViewSet): # pylint:disable=too-many-ancestors + """API viewset for interacting with RemediationSetting objects.""" + + queryset = models.RemediationSetting.objects.all() + serializer_class = serializers.RemediationSettingSerializer + filterset_class = filters.RemediationSettingFilterSet + + +class ConfigPlanViewSet( + NautobotAPIVersionMixin, + NotesViewSetMixin, + ModelViewSetMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + ListModelMixin, + BulkUpdateModelMixin, + BulkDestroyModelMixin, + GenericViewSet, +): # pylint:disable=too-many-ancestors + """API viewset for interacting with ConfigPlan objects. Does not support POST to create objects.""" + + queryset = models.ConfigPlan.objects.all() + serializer_class = serializers.ConfigPlanSerializer + filterset_class = filters.ConfigPlanFilterSet + + def get_serializer_context(self): + """Gather all custom fields for the model. Copied from nautobot.extras.api.views.CustomFieldModelViewSet.""" + content_type = ContentType.objects.get_for_model(self.queryset.model) + custom_fields = content_type.custom_fields.all() + + context = super().get_serializer_context() + context.update( + { + "custom_fields": custom_fields, + } + ) + return context + + +class GenerateIntendedConfigException(APIException): + """Exception for when the intended config cannot be generated.""" + + status_code = 400 + default_detail = "Unable to generate the intended config for this device." + default_code = "error" + + +def _nornir_task_inject_graphql_data(task, graphql_data, **kwargs): + """Inject the GraphQL data into the Nornir task host data and then run nornir_nautobot.plugins.tasks.dispatcher.dispatcher subtask. + + This is a small stub of the logic in nautobot_golden_config.nornir_plays.config_intended.run_template. + """ + task.host.data.update(graphql_data) + generated_config = task.run(task=dispatcher, name="GENERATE CONFIG", **kwargs) + return generated_config + + +class GenerateIntendedConfigView(NautobotAPIVersionMixin, GenericAPIView): + """API view for generating the intended config for a Device.""" + + name = "Generate Intended Config for Device" + permission_classes = [IsAuthenticated] + serializer_class = serializers.GenerateIntendedConfigSerializer + + def _get_diff(self, device, intended_config): + """Generate a unified diff between the provided config and the intended config stored on the Device's GoldenConfig.intended_config.""" + diff = None + try: + golden_config = device.goldenconfig + if golden_config.intended_last_success_date is not None: + prior_intended_config = golden_config.intended_config + diff = "".join( + difflib.unified_diff( + prior_intended_config.splitlines(keepends=True), + intended_config.splitlines(keepends=True), + fromfile="prior intended config", + tofile="rendered config", + ) + ) + except models.GoldenConfig.DoesNotExist: + pass + + return diff + + def _get_object(self, request, model, query_param): + """Get the requested model instance, restricted to requesting user.""" + pk = request.query_params.get(query_param) + if not pk: + raise GenerateIntendedConfigException(f"Parameter {query_param} is required") + try: + return model.objects.restrict(request.user, "view").get(pk=pk) + except model.DoesNotExist as exc: + raise GenerateIntendedConfigException(f"{model.__name__} with id '{pk}' not found") from exc + + def _get_jinja_template_path(self, settings, device, git_repository): + """Get the Jinja template path for the device in the provided git repository.""" + try: + rendered_path = render_jinja2(template_code=settings.jinja_path_template, context={"obj": device}) + except (TemplateSyntaxError, TemplateError) as exc: + raise GenerateIntendedConfigException("Error rendering Jinja path template") from exc + filesystem_path = Path(git_repository.filesystem_path) / rendered_path + if not filesystem_path.is_file(): + msg = f"Jinja template {filesystem_path} not found in git repository {git_repository}" + raise GenerateIntendedConfigException(msg) + return filesystem_path + + @extend_schema( + parameters=[ + OpenApiParameter( + name="device_id", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + ), + OpenApiParameter( + name="graphql_query_id", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + ), + ] + ) + def get(self, request, *args, **kwargs): + """Generate intended configuration for a Device.""" + device = self._get_object(request, Device, "device_id") + graphql_query = None + graphql_query_id_param = request.query_params.get("graphql_query_id") + if graphql_query_id_param: + try: + graphql_query = GraphQLQuery.objects.get(pk=request.query_params.get("graphql_query_id")) + except GraphQLQuery.DoesNotExist as exc: + raise GenerateIntendedConfigException( + f"GraphQLQuery with id '{graphql_query_id_param}' not found" + ) from exc + settings = models.GoldenConfigSetting.objects.get_for_device(device) + if not settings: + raise GenerateIntendedConfigException("No Golden Config settings found for this device") + if not settings.jinja_repository: + raise GenerateIntendedConfigException("Golden Config settings jinja_repository not set") + + if graphql_query is None: + if settings.sot_agg_query is not None: + graphql_query = settings.sot_agg_query + else: + raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set") + + if "device_id" not in graphql_query.variables: + raise GenerateIntendedConfigException("The selected GraphQL query is missing a 'device_id' variable") + + try: + git_repository = settings.jinja_repository + ensure_git_repository(git_repository) + except Exception as exc: + raise GenerateIntendedConfigException("Error trying to sync git repository") from exc + + filesystem_path = self._get_jinja_template_path(settings, device, git_repository) + + status_code, graphql_data = graph_ql_query(request, device, graphql_query.query) + if status_code == status.HTTP_200_OK: + try: + intended_config = self._render_config_nornir_serial( + device=device, + jinja_template=filesystem_path.name, + jinja_root_path=filesystem_path.parent, + graphql_data=graphql_data, + ) + except Exception as exc: + raise GenerateIntendedConfigException(f"Error rendering Jinja template: {exc}") from exc + + diff = self._get_diff(device, intended_config) + + return Response( + data={ + "intended_config": intended_config, + "intended_config_lines": intended_config.split("\n"), + "graphql_data": graphql_data, + "diff": diff, + "diff_lines": diff.split("\n") if diff else [], + }, + status=status.HTTP_200_OK, + ) + + raise GenerateIntendedConfigException("Unable to generate the intended config for this device") + + def _render_config_nornir_serial(self, device, jinja_template, jinja_root_path, graphql_data): + """Render the Jinja template for the device using Nornir serial runner. + + This is a small stub of the logic in nornir_plays.config_intended.config_intended. + """ + jinja_env = get_django_env() + with InitNornir( + runner={"plugin": "serial"}, + logging={"enabled": False}, + inventory={ + "plugin": "nautobot-inventory", + "options": { + "credentials_class": NORNIR_SETTINGS.get("credentials"), + "params": NORNIR_SETTINGS.get("inventory_params"), + "queryset": Device.objects.filter(pk=device.pk), + "defaults": {"now": make_aware(datetime.datetime.now())}, + }, + }, + ) as nornir_obj: + results = nornir_obj.run( + task=_nornir_task_inject_graphql_data, + name="REST API GENERATE CONFIG", + graphql_data=graphql_data, + obj=device, # Used by the nornir tasks for logging to the logger below + logger=logging.getLogger( + dispatcher.__module__ + ), # The nornir tasks are built for logging to a JobResult, pass a standard logger here + jinja_template=jinja_template, + jinja_root_path=jinja_root_path, + output_file_location="/dev/null", # The nornir task outputs the templated config to a file, but this API doesn't need it + jinja_filters=jinja_env.filters, + jinja_env=jinja_env, + **dispatch_params( + "generate_config", device.platform.network_driver, logging.getLogger(dispatch_params.__module__) + ), + ) + if results[device.name].failed: + if results[device.name].exception: # pylint: disable=no-else-raise + raise results[device.name].exception + else: + raise GenerateIntendedConfigException( + f"Error generating intended config for {device.name}: {results[device.name].result}" + ) + else: + return results[device.name][1][1][0].result["config"] diff --git a/nautobot_golden_config/filters.py b/nautobot_golden_config/filters.py index 333644c9..e4ed3187 100644 --- a/nautobot_golden_config/filters.py +++ b/nautobot_golden_config/filters.py @@ -1,17 +1,452 @@ """Filtering for nautobot_golden_config.""" -from nautobot.apps.filters import NameSearchFilterSet, NautobotFilterSet +import django_filters +from nautobot.apps.filters import ( + MultiValueDateTimeFilter, + NaturalKeyOrPKMultipleChoiceFilter, + NautobotFilterSet, + SearchFilter, + StatusFilter, + TreeNodeMultipleChoiceFilter, +) +from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup +from nautobot.extras.models import JobResult, Role, Status +from nautobot.tenancy.models import Tenant, TenantGroup from nautobot_golden_config import models -class ComplianceFeatureFilterSet(NautobotFilterSet, NameSearchFilterSet): # pylint: disable=too-many-ancestors - """Filter for ComplianceFeature.""" +class GoldenConfigFilterSet(NautobotFilterSet): + """Filter capabilities for GoldenConfig instances.""" + + @staticmethod + def _get_filter_lookup_dict(existing_filter): + """Extend method to account for isnull on datetime types.""" + # Choose the lookup expression map based on the filter type + lookup_map = NautobotFilterSet._get_filter_lookup_dict(existing_filter) + if isinstance(existing_filter, MultiValueDateTimeFilter): + lookup_map.update({"isnull": "isnull"}) + return lookup_map + + q = SearchFilter( + filter_predicates={ + "device__name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + tenant_group_id = TreeNodeMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + field_name="device__tenant__tenant_group", + to_field_name="id", + label="Tenant Group (ID)", + ) + tenant_group = TreeNodeMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + field_name="device__tenant__tenant_group", + to_field_name="name", + label="Tenant Group (name)", + ) + tenant = NaturalKeyOrPKMultipleChoiceFilter( + queryset=Tenant.objects.all(), + field_name="device__tenant", + to_field_name="name", + label="Tenant (name or ID)", + ) + location_id = TreeNodeMultipleChoiceFilter( + # Not limiting to content_type=dcim.device to allow parent locations to be included + # i.e. include all Sites in a Region, even though Region can't be assigned to a Device + queryset=Location.objects.all(), + field_name="device__location", + to_field_name="id", + label="Location (ID)", + ) + location = TreeNodeMultipleChoiceFilter( + # Not limiting to content_type=dcim.device to allow parent locations to be included + # i.e. include all sites in a Region, even though Region can't be assigned to a Device + queryset=Location.objects.all(), + field_name="device__location", + to_field_name="name", + label="Location (name)", + ) + rack_group_id = TreeNodeMultipleChoiceFilter( + queryset=RackGroup.objects.all(), + field_name="device__rack__rack_group", + to_field_name="id", + label="Rack group (ID)", + ) + rack_group = TreeNodeMultipleChoiceFilter( + queryset=RackGroup.objects.all(), + field_name="device__rack__rack_group", + to_field_name="name", + label="Rack group (name)", + ) + rack = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__rack", + queryset=Rack.objects.all(), + to_field_name="name", + label="Rack (name or ID)", + ) + role = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__role", + queryset=Role.objects.filter(content_types__model="device"), + to_field_name="name", + label="Role (name or ID)", + ) + manufacturer = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__device_type__manufacturer", + queryset=Manufacturer.objects.all(), + to_field_name="name", + label="Manufacturer (name or ID)", + ) + platform = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__platform", + queryset=Platform.objects.all(), + to_field_name="name", + label="Platform (name or ID)", + ) + device_status = StatusFilter( + field_name="device__status", + queryset=Status.objects.all(), + label="Device Status", + ) + device_type = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__device_type", + queryset=DeviceType.objects.all(), + to_field_name="model", + label="DeviceType (model or ID)", + ) + device = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device", + queryset=Device.objects.all(), + to_field_name="name", + label="Device (name or ID)", + ) class Meta: - """Meta attributes for filter.""" + """Meta class attributes for GoldenConfigFilter.""" + + model = models.GoldenConfig + distinct = True + fields = "__all__" + + +class ConfigComplianceFilterSet(GoldenConfigFilterSet): # pylint: disable=too-many-ancestors + """Filter capabilities for ConfigCompliance instances.""" + + feature_id = django_filters.ModelMultipleChoiceFilter( + field_name="rule__feature", + queryset=models.ComplianceFeature.objects.all(), + label="ComplianceFeature (ID)", + ) + feature = django_filters.ModelMultipleChoiceFilter( + field_name="rule__feature__slug", + queryset=models.ComplianceFeature.objects.all(), + to_field_name="slug", + label="ComplianceFeature (slug)", + ) + + class Meta: + """Meta class attributes for ConfigComplianceFilter.""" + + model = models.ConfigCompliance + fields = "__all__" + + +class ComplianceFeatureFilterSet(NautobotFilterSet): + """Inherits Base Class NautobotFilterSet.""" + + q = SearchFilter( + filter_predicates={ + "name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + + class Meta: + """Boilerplate filter Meta data for compliance feature.""" model = models.ComplianceFeature + fields = "__all__" + + +class ComplianceRuleFilterSet(NautobotFilterSet): + """Inherits Base Class NautobotFilterSet.""" + + q = SearchFilter( + filter_predicates={ + "feature__name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + platform = NaturalKeyOrPKMultipleChoiceFilter( + field_name="platform", + queryset=Platform.objects.all(), + to_field_name="name", + label="Platform (name or ID)", + ) + + class Meta: + """Boilerplate filter Meta data for compliance rule.""" + + model = models.ComplianceRule + fields = "__all__" + + +class ConfigRemoveFilterSet(NautobotFilterSet): + """Inherits Base Class NautobotFilterSet.""" + + q = SearchFilter( + filter_predicates={ + "name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + platform = NaturalKeyOrPKMultipleChoiceFilter( + field_name="platform", + queryset=Platform.objects.all(), + to_field_name="name", + label="Platform (name or ID)", + ) + + class Meta: + """Boilerplate filter Meta data for Config Remove.""" + + model = models.ConfigRemove + fields = "__all__" + + +class ConfigReplaceFilterSet(NautobotFilterSet): + """Inherits Base Class NautobotFilterSet.""" + + q = SearchFilter( + filter_predicates={ + "name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + platform = NaturalKeyOrPKMultipleChoiceFilter( + field_name="platform", + queryset=Platform.objects.all(), + to_field_name="name", + label="Platform (name or ID)", + ) + + class Meta: + """Boilerplate filter Meta data for Config Replace.""" + + model = models.ConfigReplace + fields = "__all__" + + +class GoldenConfigSettingFilterSet(NautobotFilterSet): + """Inherits Base Class NautobotFilterSet.""" + + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label="Device (ID)", + method="filter_device_id", + ) + + def filter_device_id(self, queryset, name, value): # pylint: disable=unused-argument + """Filter by Device ID.""" + if not value: + return queryset + golden_config_setting_ids = [] + for instance in value: + if isinstance(instance, Device): + device = instance + else: + device = Device.objects.get(id=instance) + golden_config_setting = models.GoldenConfigSetting.objects.get_for_device(device) + if golden_config_setting is not None: + golden_config_setting_ids.append(golden_config_setting.id) + return queryset.filter(id__in=golden_config_setting_ids) + + class Meta: + """Boilerplate filter Meta data for Config Remove.""" + + model = models.GoldenConfigSetting + fields = "__all__" + + +class RemediationSettingFilterSet(NautobotFilterSet): + """Inherits Base Class CustomFieldModelFilterSet.""" + + q = SearchFilter( + filter_predicates={ + "platform__name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + "remediation_type": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + platform = django_filters.ModelMultipleChoiceFilter( + field_name="platform__name", + queryset=Platform.objects.all(), + to_field_name="name", + label="Platform Name", + ) + platform_id = django_filters.ModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label="Platform ID", + ) + + class Meta: + """Boilerplate filter Meta data for Remediation Setting.""" + + model = models.RemediationSetting + fields = "__all__" + + +class ConfigPlanFilterSet(NautobotFilterSet): + """Inherits Base Class NautobotFilterSet.""" + + q = SearchFilter( + filter_predicates={ + "device__name": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + "change_control_id": { + "lookup_expr": "icontains", + "preprocessor": str, + }, + }, + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label="Device ID", + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name="device__name", + queryset=Device.objects.all(), + to_field_name="name", + label="Device Name", + ) + feature_id = django_filters.ModelMultipleChoiceFilter( + field_name="feature__id", + queryset=models.ComplianceFeature.objects.all(), + to_field_name="id", + label="Feature ID", + ) + feature = django_filters.ModelMultipleChoiceFilter( + field_name="feature__name", + queryset=models.ComplianceFeature.objects.all(), + to_field_name="name", + label="Feature Name", + ) + plan_result_id = django_filters.ModelMultipleChoiceFilter( + queryset=JobResult.objects.filter(config_plan__isnull=False).distinct(), + label="Plan JobResult ID", + to_field_name="id", + ) + tenant_group_id = TreeNodeMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + field_name="device__tenant__tenant_group", + to_field_name="id", + label="Tenant Group (ID)", + ) + tenant_group = TreeNodeMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + field_name="device__tenant__tenant_group", + to_field_name="name", + label="Tenant Group (name)", + ) + tenant = NaturalKeyOrPKMultipleChoiceFilter( + queryset=Tenant.objects.all(), + field_name="device__tenant", + to_field_name="name", + label="Tenant (name or ID)", + ) + manufacturer = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__device_type__manufacturer", + queryset=Manufacturer.objects.all(), + to_field_name="name", + label="Manufacturer (name or ID)", + ) + platform = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__platform", + queryset=Platform.objects.all(), + to_field_name="name", + label="Platform (name or ID)", + ) + location_id = TreeNodeMultipleChoiceFilter( + # Not limiting to content_type=dcim.device to allow parent locations to be included + # i.e. include all Sites in a Region, even though Region can't be assigned to a Device + queryset=Location.objects.all(), + field_name="device__location", + to_field_name="id", + label="Location (ID)", + ) + location = TreeNodeMultipleChoiceFilter( + # Not limiting to content_type=dcim.device to allow parent locations to be included + # i.e. include all sites in a Region, even though Region can't be assigned to a Device + queryset=Location.objects.all(), + field_name="device__location", + to_field_name="name", + label="Location (name)", + ) + deploy_result_id = django_filters.ModelMultipleChoiceFilter( + queryset=JobResult.objects.filter(config_plan__isnull=False).distinct(), + label="Deploy JobResult ID", + to_field_name="id", + ) + change_control_id = django_filters.CharFilter( + field_name="change_control_id", + lookup_expr="exact", + ) + rack_group_id = TreeNodeMultipleChoiceFilter( + queryset=RackGroup.objects.all(), + field_name="device__rack__rack_group", + to_field_name="id", + label="Rack group (ID)", + ) + rack_group = TreeNodeMultipleChoiceFilter( + queryset=RackGroup.objects.all(), + field_name="device__rack__rack_group", + to_field_name="name", + label="Rack group (name)", + ) + rack = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__rack", + queryset=Rack.objects.all(), + to_field_name="name", + label="Rack (name or ID)", + ) + role = NaturalKeyOrPKMultipleChoiceFilter( + field_name="device__role", + queryset=Role.objects.filter(content_types__model="device"), + to_field_name="name", + label="Role (name or ID)", + ) + status_id = django_filters.ModelMultipleChoiceFilter( + # field_name="status__id", + queryset=Status.objects.all(), + label="Status ID", + ) + status = django_filters.ModelMultipleChoiceFilter( + field_name="status__name", + queryset=Status.objects.all(), + to_field_name="name", + label="Status", + ) + + class Meta: + """Boilerplate filter Meta data for Config Plan.""" - # add any fields from the model that you would like to filter your searches by using those - fields = ["id", "name", "description"] + model = models.ConfigPlan + fields = "__all__" diff --git a/nautobot_golden_config/forms.py b/nautobot_golden_config/forms.py index e5a35c99..2bd45559 100644 --- a/nautobot_golden_config/forms.py +++ b/nautobot_golden_config/forms.py @@ -1,47 +1,616 @@ -"""Forms for nautobot_golden_config.""" +"""Forms for Device Configuration Backup.""" +# pylint: disable=too-many-ancestors -from django import forms -from nautobot.apps.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm, TagsBulkEditFormMixin +import json + +import django.forms as django_forms +from nautobot.apps import forms +from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup +from nautobot.extras.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm +from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, JobResult, Role, Status, Tag +from nautobot.tenancy.models import Tenant, TenantGroup from nautobot_golden_config import models +from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice + +# ConfigCompliance + + +class DeviceRelatedFilterForm(NautobotFilterForm): # pylint: disable=nb-no-model-found + """Base FilterForm for below FilterForms.""" + + tenant_group_id = forms.DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), to_field_name="id", required=False, label="Tenant group ID" + ) + tenant_group = forms.DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name="name", + required=False, + label="Tenant group name", + null_option="None", + ) + tenant = forms.DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + to_field_name="name", + required=False, + null_option="None", + query_params={"group": "$tenant_group"}, + ) + location_id = forms.DynamicModelMultipleChoiceField( + # Not limiting to query_params={"content_type": "dcim.device" to allow parent locations to be included + # i.e. include all sites in a Region, even though Region can't be assigned to a Device + queryset=Location.objects.all(), + to_field_name="id", + required=False, + label="Location ID", + ) + location = forms.DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), to_field_name="name", required=False, label="Location name" + ) + rack_group_id = forms.DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + to_field_name="id", + required=False, + label="Rack group ID", + query_params={"location": "$location"}, + ) + rack_group = forms.DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + to_field_name="name", + required=False, + label="Rack group name", + query_params={"location": "$location"}, + ) + rack_id = forms.DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label="Rack", + null_option="None", + query_params={ + "location": "$location", + "group_id": "$rack_group_id", + }, + ) + role = forms.DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), + to_field_name="name", + required=False, + query_params={"content_types": "dcim.device"}, + ) + manufacturer = forms.DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), to_field_name="name", required=False, label="Manufacturer" + ) + device_type = forms.DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label="Model", + display_field="model", + query_params={"manufacturer": "$manufacturer"}, + ) + platform = forms.DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), to_field_name="name", required=False, null_option="None" + ) + device = forms.DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), required=False, null_option="None", label="Device", to_field_name="name" + ) + +class GoldenConfigFilterForm(DeviceRelatedFilterForm): + """Filter Form for GoldenConfig.""" -class ComplianceFeatureForm(NautobotModelForm): # pylint: disable=too-many-ancestors - """ComplianceFeature creation/edit form.""" + model = models.GoldenConfig + field_order = [ + "q", + "tenant_group", + "tenant", + "location_id", + "location", + "rack_group_id", + "rack_group", + "rack_id", + "role", + "manufacturer", + "platform", + "device_status", + "device_type", + "device", + ] + q = django_forms.CharField(required=False, label="Search") + + +class GoldenConfigBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for GoldenConfig instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.GoldenConfig.objects.all(), widget=django_forms.MultipleHiddenInput + ) + # description = django_forms.CharField(max_length=200, required=False) class Meta: - """Meta attributes.""" + """Boilerplate form Meta data for GoldenConfig.""" - model = models.ComplianceFeature - fields = [ - "name", - "description", - ] + nullable_fields = [] + + +class ConfigComplianceFilterForm(DeviceRelatedFilterForm): + """Filter Form for ConfigCompliance instances.""" + + model = models.ConfigCompliance + # Set field order to be explicit + field_order = [ + "q", + "tenant_group", + "tenant", + "location_id", + "location", + "rack_group_id", + "rack_group", + "rack_id", + "role", + "manufacturer", + "platform", + "device_status", + "device_type", + "device", + ] + + q = django_forms.CharField(required=False, label="Search") + + def __init__(self, *args, **kwargs): + """Required for status to work.""" + super().__init__(*args, **kwargs) + self.fields["device_status"] = forms.DynamicModelMultipleChoiceField( + required=False, + queryset=Status.objects.all(), + query_params={"content_types": Device._meta.label_lower}, + display_field="label", + label="Device Status", + to_field_name="name", + ) + self.order_fields(self.field_order) # Reorder fields again + + +# ComplianceRule -class ComplianceFeatureBulkEditForm(TagsBulkEditFormMixin, NautobotBulkEditForm): # pylint: disable=too-many-ancestors - """ComplianceFeature bulk edit form.""" +class ComplianceRuleForm(NautobotModelForm): + """Filter Form for ComplianceRule instances.""" - pk = forms.ModelMultipleChoiceField(queryset=models.ComplianceFeature.objects.all(), widget=forms.MultipleHiddenInput) - description = forms.CharField(required=False) + platform = forms.DynamicModelChoiceField(queryset=Platform.objects.all()) class Meta: - """Meta attributes.""" + """Boilerplate form Meta data for compliance rule.""" + + model = models.ComplianceRule + fields = "__all__" + + +class ComplianceRuleFilterForm(NautobotFilterForm): + """Form for ComplianceRule instances.""" + + model = models.ComplianceRule + + q = django_forms.CharField(required=False, label="Search") + platform = forms.DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), to_field_name="name", required=False, null_option="None" + ) + + feature = forms.DynamicModelMultipleChoiceField(queryset=models.ComplianceFeature.objects.all(), required=False) + + +class ComplianceRuleBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for ComplianceRule instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.ComplianceRule.objects.all(), widget=django_forms.MultipleHiddenInput + ) + description = django_forms.CharField(max_length=200, required=False) + config_type = django_forms.ChoiceField( + required=False, + choices=forms.add_blank_choice(ComplianceRuleConfigTypeChoice), + ) + config_ordered = django_forms.NullBooleanField(required=False, widget=forms.BulkEditNullBooleanSelect()) + custom_compliance = django_forms.NullBooleanField(required=False, widget=forms.BulkEditNullBooleanSelect()) + config_remediation = django_forms.NullBooleanField(required=False, widget=forms.BulkEditNullBooleanSelect()) + + class Meta: + """Boilerplate form Meta data for ComplianceRule.""" + + nullable_fields = [] + + +# ComplianceFeature - nullable_fields = [ - "description", - ] + +class ComplianceFeatureForm(NautobotModelForm): + """Filter Form for ComplianceFeature instances.""" + + slug = forms.SlugField() # TODO: 2.1: Change from slugs once django-pivot is figured out + + class Meta: + """Boilerplate form Meta data for compliance feature.""" + + model = models.ComplianceFeature + fields = "__all__" class ComplianceFeatureFilterForm(NautobotFilterForm): - """Filter form to filter searches.""" + """Form for ComplianceFeature instances.""" model = models.ComplianceFeature - field_order = ["q", "name"] + q = django_forms.CharField(required=False, label="Search") + name = forms.DynamicModelChoiceField(queryset=models.ComplianceFeature.objects.all(), required=False) + + +class ComplianceFeatureBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for ComplianceFeature instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.ComplianceFeature.objects.all(), widget=django_forms.MultipleHiddenInput + ) + description = django_forms.CharField(max_length=200, required=False) + + class Meta: + """Boilerplate form Meta data for ComplianceFeature.""" + + nullable_fields = [] + + +# ConfigRemove + + +class ConfigRemoveForm(NautobotModelForm): + """Filter Form for Line Removal instances.""" + + platform = forms.DynamicModelChoiceField(queryset=Platform.objects.all()) + + class Meta: + """Boilerplate form Meta data for removal feature.""" - q = forms.CharField( + model = models.ConfigRemove + fields = "__all__" + + +class ConfigRemoveFilterForm(NautobotFilterForm): + """Filter Form for Line Removal.""" + + model = models.ConfigRemove + platform = forms.DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), to_field_name="name", required=False, null_option="None" + ) + name = forms.DynamicModelChoiceField( + queryset=models.ConfigRemove.objects.all(), to_field_name="name", required=False + ) + + +class ConfigRemoveBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for ConfigRemove instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.ConfigRemove.objects.all(), widget=django_forms.MultipleHiddenInput + ) + description = django_forms.CharField(max_length=200, required=False) + + class Meta: + """Boilerplate form Meta data for ConfigRemove.""" + + nullable_fields = [] + + +# ConfigReplace + + +class ConfigReplaceForm(NautobotModelForm): + """Filter Form for Line Removal instances.""" + + platform = forms.DynamicModelChoiceField(queryset=Platform.objects.all()) + + class Meta: + """Boilerplate form Meta data for removal feature.""" + + model = models.ConfigReplace + fields = "__all__" + + +class ConfigReplaceFilterForm(NautobotFilterForm): + """Filter Form for Line Replacement.""" + + model = models.ConfigReplace + + platform = forms.DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), to_field_name="name", required=False, null_option="None" + ) + name = forms.DynamicModelChoiceField( + queryset=models.ConfigReplace.objects.all(), to_field_name="name", required=False + ) + + +class ConfigReplaceBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for ConfigReplace instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.ConfigReplace.objects.all(), widget=django_forms.MultipleHiddenInput + ) + description = django_forms.CharField(max_length=200, required=False) + + class Meta: + """Boilerplate form Meta data for ConfigReplace.""" + + nullable_fields = [] + + +# GoldenConfigSetting + + +class GoldenConfigSettingForm(NautobotModelForm): + """Filter Form for GoldenConfigSettingForm instances.""" + + slug = forms.SlugField() + dynamic_group = django_forms.ModelChoiceField(queryset=DynamicGroup.objects.all()) + + class Meta: + """Filter Form Meta Data for GoldenConfigSettingForm instances.""" + + model = models.GoldenConfigSetting + fields = "__all__" + + +class GoldenConfigSettingFilterForm(NautobotFilterForm): + """Form for GoldenConfigSetting instances.""" + + model = models.GoldenConfigSetting + + q = django_forms.CharField(required=False, label="Search") + name = django_forms.CharField(required=False) + weight = django_forms.IntegerField(required=False) + backup_repository = django_forms.ModelChoiceField( + queryset=GitRepository.objects.filter(provided_contents__contains="nautobot_golden_config.backupconfigs"), + required=False, + ) + intended_repository = django_forms.ModelChoiceField( + queryset=GitRepository.objects.filter(provided_contents__contains="nautobot_golden_config.intendedconfigs"), required=False, - label="Search", - help_text="Search within Name or Slug.", ) - name = forms.CharField(required=False, label="Name") + jinja_repository = django_forms.ModelChoiceField( + queryset=GitRepository.objects.filter(provided_contents__contains="nautobot_golden_config.jinjatemplate"), + required=False, + ) + + +class GoldenConfigSettingBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for GoldenConfigSetting instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.GoldenConfigSetting.objects.all(), widget=django_forms.MultipleHiddenInput + ) + + class Meta: + """Boilerplate form Meta data for GoldenConfigSetting.""" + + nullable_fields = [] + + +# Remediation Setting +class RemediationSettingForm(NautobotModelForm): + """Create/Update Form for Remediation Settings instances.""" + + class Meta: + """Boilerplate form Meta data for Remediation Settings.""" + + model = models.RemediationSetting + fields = "__all__" + + +class RemediationSettingFilterForm(NautobotFilterForm): + """Filter Form for Remediation Settings.""" + + model = models.RemediationSetting + q = django_forms.CharField(required=False, label="Search") + platform = forms.DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), required=False, display_field="name", to_field_name="name" + ) + remediation_type = django_forms.ChoiceField( + choices=forms.add_blank_choice(RemediationTypeChoice), + required=False, + widget=django_forms.Select(), + label="Remediation Type", + ) + + +class RemediationSettingBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for RemediationSetting instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.RemediationSetting.objects.all(), widget=django_forms.MultipleHiddenInput + ) + remediation_type = django_forms.ChoiceField(choices=RemediationTypeChoice, label="Remediation Type") + + class Meta: + """Boilerplate form Meta data for RemediationSetting.""" + + nullable_fields = [] + + +# ConfigPlan + + +class ConfigPlanForm(NautobotModelForm): + """Form for ConfigPlan instances.""" + + feature = forms.DynamicModelMultipleChoiceField( + queryset=models.ComplianceFeature.objects.all(), + display_field="name", + help_text="Note: Selecting no features will generate plans for all applicable features.", + ) + commands = django_forms.CharField( + widget=django_forms.Textarea, + help_text=( + "Enter your configuration template here representing CLI configuration.
" + 'You may use Jinja2 templating. Example: {% if "foo" in bar %}foo{% endif %}
' + "You can also reference the device object with obj.
" + "For example: hostname {{ obj.name }} or ip address {{ obj.primary_ip4.host }}" + ), + ) + + tenant_group = forms.DynamicModelMultipleChoiceField(queryset=TenantGroup.objects.all(), required=False) + tenant = forms.DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), required=False, query_params={"tenant_group": "$tenant_group"} + ) + # Requires https://github.com/nautobot/nautobot-app-golden-config/issues/430 + location = forms.DynamicModelMultipleChoiceField(queryset=Location.objects.all(), required=False) + rack_group = forms.DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), required=False, query_params={"location": "$location"} + ) + rack = forms.DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), required=False, query_params={"rack_group": "$rack_group", "location": "$location"} + ) + role = forms.DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), required=False, query_params={"content_types": "dcim.device"} + ) + manufacturer = forms.DynamicModelMultipleChoiceField(queryset=Manufacturer.objects.all(), required=False) + platform = forms.DynamicModelMultipleChoiceField(queryset=Platform.objects.all(), required=False) + device_type = forms.DynamicModelMultipleChoiceField(queryset=DeviceType.objects.all(), required=False) + device = forms.DynamicModelMultipleChoiceField(queryset=Device.objects.all(), required=False) + tags = forms.DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), query_params={"content_types": "dcim.device"}, required=False + ) + status = forms.DynamicModelMultipleChoiceField( + queryset=Status.objects.all(), query_params={"content_types": "dcim.device"}, required=False + ) + + def __init__(self, *args, **kwargs): + """Method to get data from Python -> Django template -> JS in support of toggle form fields.""" + super().__init__(*args, **kwargs) + hide_form_data = [ + { + "event_field": "id_plan_type", + "values": [ + {"name": "manual", "show": ["id_commands"], "hide": ["id_feature"]}, + {"name": "missing", "show": ["id_feature"], "hide": ["id_commands"]}, + {"name": "intended", "show": ["id_feature"], "hide": ["id_commands"]}, + {"name": "remediation", "show": ["id_feature"], "hide": ["id_commands"]}, + {"name": "", "show": [], "hide": ["id_commands", "id_feature"]}, + ], + } + ] + # Example of how to use this `JSON.parse('{{ form.hide_form_data|safe }}')` + self.hide_form_data = json.dumps(hide_form_data) + + class Meta: + """Boilerplate form Meta data for ConfigPlan.""" + + model = models.ConfigPlan + fields = "__all__" + + +class ConfigPlanUpdateForm(NautobotModelForm): # pylint: disable=nb-sub-class-name + """Form for ConfigPlan instances.""" + + status = forms.DynamicModelChoiceField( + queryset=Status.objects.all(), + query_params={"content_types": models.ConfigPlan._meta.label_lower}, + ) + tags = forms.DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), query_params={"content_types": "dcim.device"}, required=False + ) + + class Meta: + """Boilerplate form Meta data for ConfigPlan.""" + + model = models.ConfigPlan + fields = ( # pylint: disable=nb-use-fields-all + "change_control_id", + "change_control_url", + "status", + "tags", + ) + + +class ConfigPlanFilterForm(DeviceRelatedFilterForm): + """Filter Form for ConfigPlan.""" + + model = models.ConfigPlan + + q = django_forms.CharField(required=False, label="Search") + # device_id = forms.DynamicModelMultipleChoiceField( + # queryset=Device.objects.all(), required=False, null_option="None", label="Device" + # ) + created__lte = django_forms.DateTimeField(label="Created Before", required=False, widget=forms.DatePicker()) + created__gte = django_forms.DateTimeField(label="Created After", required=False, widget=forms.DatePicker()) + plan_type = django_forms.ChoiceField( + choices=forms.add_blank_choice(ConfigPlanTypeChoice), + required=False, + widget=django_forms.Select(), + label="Plan Type", + ) + feature = forms.DynamicModelMultipleChoiceField( + queryset=models.ComplianceFeature.objects.all(), + required=False, + null_option="None", + label="Feature", + to_field_name="name", + ) + change_control_id = django_forms.CharField(required=False, label="Change Control ID") + plan_result_id = forms.DynamicModelMultipleChoiceField( + queryset=JobResult.objects.all(), + query_params={"job_model": "Generate Config Plans"}, + label="Plan Result", + required=False, + display_field="date_created", + ) + deploy_result_id = forms.DynamicModelMultipleChoiceField( + queryset=JobResult.objects.all(), + query_params={"job_model": "Deploy Config Plans"}, + label="Deploy Result", + required=False, + display_field="date_created", + ) + status = forms.DynamicModelMultipleChoiceField( + required=False, + queryset=Status.objects.all(), + query_params={"content_types": models.ConfigPlan._meta.label_lower}, + display_field="label", + label="Status", + to_field_name="name", + ) + tags = forms.TagFilterField(model) + + +class ConfigPlanBulkEditForm(NautobotBulkEditForm): + """BulkEdit form for ConfigPlan instances.""" + + pk = django_forms.ModelMultipleChoiceField( + queryset=models.ConfigPlan.objects.all(), widget=django_forms.MultipleHiddenInput + ) + status = forms.DynamicModelChoiceField( + queryset=Status.objects.all(), + query_params={"content_types": models.ConfigPlan._meta.label_lower}, + required=False, + ) + change_control_id = django_forms.CharField(required=False, label="Change Control ID") + change_control_url = django_forms.URLField(required=False, label="Change Control URL") + + class Meta: + """Boilerplate form Meta data for ConfigPlan.""" + + nullable_fields = [ + "change_control_id", + "change_control_url", + "tags", + ] + + +class GenerateIntendedConfigForm(django_forms.Form): + """Form for generating intended configuration.""" + + device = forms.DynamicModelChoiceField( + queryset=Device.objects.all(), + required=True, + label="Device", + ) + graphql_query = forms.DynamicModelChoiceField( + queryset=GraphQLQuery.objects.all(), + required=True, + label="GraphQL Query", + query_params={"nautobot_golden_config_graphql_query_variables": "device_id"}, + ) diff --git a/nautobot_golden_config/models.py b/nautobot_golden_config/models.py index afc72d2c..bbedd895 100644 --- a/nautobot_golden_config/models.py +++ b/nautobot_golden_config/models.py @@ -1,38 +1,842 @@ -"""Models for Golden Config.""" +"""Django Models for tracking the configuration compliance per feature and device.""" -# Django imports +import json +import logging +import os + +from deepdiff import DeepDiff +from django.core.exceptions import ValidationError from django.db import models +from django.db.models.manager import BaseManager +from django.utils.module_loading import import_string +from hier_config import Host as HierConfigHost +from nautobot.apps.models import RestrictedQuerySet +from nautobot.apps.utils import render_jinja2 +from nautobot.core.models.generics import PrimaryModel +from nautobot.core.models.utils import serialize_object, serialize_object_v2 +from nautobot.dcim.models import Device +from nautobot.extras.models import ObjectChange +from nautobot.extras.models.statuses import StatusField +from nautobot.extras.utils import extras_features +from netutils.config.compliance import feature_compliance +from xmldiff import actions, main + +from nautobot_golden_config.choices import ComplianceRuleConfigTypeChoice, ConfigPlanTypeChoice, RemediationTypeChoice +from nautobot_golden_config.utilities.constant import ENABLE_SOTAGG, PLUGIN_CFG + +LOGGER = logging.getLogger(__name__) +GRAPHQL_STR_START = "query ($device_id: ID!)" + +ERROR_MSG = ( + "There was an issue with the data that was returned by your get_custom_compliance function. " + "This is a local issue that requires the attention of your systems administrator and not something " + "that can be fixed within the Golden Config app. " +) +MISSING_MSG = ( + ERROR_MSG + "Specifically the `{}` key was not found in value the get_custom_compliance function provided." +) +VALIDATION_MSG = ( + ERROR_MSG + "Specifically the key {} was expected to be of type(s) {} and the value of {} was not that type(s)." +) + +CUSTOM_FUNCTIONS = { + "get_custom_compliance": "custom", + "get_custom_remediation": RemediationTypeChoice.TYPE_CUSTOM, +} + + +def _is_jsonable(val): + """Check is value can be converted to json.""" + try: + json.dumps(val) + return True + except (TypeError, OverflowError): + return False + + +def _null_to_empty(val): + """Convert to empty string if the value is currently null.""" + if not val: + return "" + return val + + +def _get_cli_compliance(obj): + """This function performs the actual compliance for cli configuration.""" + feature = { + "ordered": obj.rule.config_ordered, + "name": obj.rule, + } + feature.update({"section": obj.rule.match_config.splitlines()}) + value = feature_compliance( + feature, obj.actual, obj.intended, obj.device.platform.network_driver_mappings.get("netutils_parser") + ) + compliance = value["compliant"] + if compliance: + compliance_int = 1 + ordered = value["ordered_compliant"] + else: + compliance_int = 0 + ordered = value["ordered_compliant"] + missing = _null_to_empty(value["missing"]) + extra = _null_to_empty(value["extra"]) + return { + "compliance": compliance, + "compliance_int": compliance_int, + "ordered": ordered, + "missing": missing, + "extra": extra, + } + + +def _get_json_compliance(obj): + """This function performs the actual compliance for json serializable data.""" + + def _normalize_diff(diff, path_to_diff): + """Normalizes the diff to a list of keys and list indexes that have changed.""" + dictionary_items = list(diff.get(f"dictionary_item_{path_to_diff}", [])) + list_items = list(diff.get(f"iterable_item_{path_to_diff}", {}).keys()) + values_changed = list(diff.get("values_changed", {}).keys()) + type_changes = list(diff.get("type_changes", {}).keys()) + return dictionary_items + list_items + values_changed + type_changes + + diff = DeepDiff(obj.actual, obj.intended, ignore_order=obj.ordered, report_repetition=True) + if not diff: + compliance_int = 1 + compliance = True + ordered = True + missing = "" + extra = "" + else: + compliance_int = 0 + compliance = False + ordered = False + missing = _null_to_empty(_normalize_diff(diff, "added")) + extra = _null_to_empty(_normalize_diff(diff, "removed")) + + return { + "compliance": compliance, + "compliance_int": compliance_int, + "ordered": ordered, + "missing": missing, + "extra": extra, + } + -# Nautobot imports -from nautobot.apps.models import PrimaryModel +def _get_xml_compliance(obj): + """This function performs the actual compliance for xml serializable data.""" -# from nautobot.apps.models import extras_features -# If you want to use the extras_features decorator please reference the following documentation -# https://docs.nautobot.com/projects/core/en/latest/plugins/development/#using-the-extras_features-decorator-for-graphql -# Then based on your reading you may decide to put the following decorator before the declaration of your class -# @extras_features("custom_fields", "custom_validators", "relationships", "graphql") + def _normalize_diff(diff): + """Format the diff output to a list of nodes with values that have updated.""" + formatted_diff = [] + for operation in diff: + if isinstance(operation, actions.UpdateTextIn): + formatted_operation = f"{operation.node}, {operation.text}" + formatted_diff.append(formatted_operation) + return "\n".join(formatted_diff) + # Options for the diff operation. These are set to prefer updates over node insertions/deletions. + diff_options = { + "F": 0.1, + "fast_match": True, + } + missing = main.diff_texts(obj.actual, obj.intended, diff_options=diff_options) + extra = main.diff_texts(obj.intended, obj.actual, diff_options=diff_options) -# If you want to choose a specific model to overload in your class declaration, please reference the following documentation: -# how to chose a database model: https://docs.nautobot.com/projects/core/en/stable/plugins/development/#database-models + compliance = not missing and not extra + compliance_int = int(compliance) + ordered = obj.ordered + missing = _null_to_empty(_normalize_diff(missing)) + extra = _null_to_empty(_normalize_diff(extra)) + + return { + "compliance": compliance, + "compliance_int": compliance_int, + "ordered": ordered, + "missing": missing, + "extra": extra, + } + + +def _verify_get_custom_compliance_data(compliance_details): + """This function verifies the data is as expected when a custom function is used.""" + for val in ["compliance", "compliance_int", "ordered", "missing", "extra"]: + try: + compliance_details[val] + except KeyError: + raise ValidationError(MISSING_MSG.format(val)) from KeyError + for val in ["compliance", "ordered"]: + if compliance_details[val] not in [True, False]: + raise ValidationError(VALIDATION_MSG.format(val, "Boolean", compliance_details[val])) + if compliance_details["compliance_int"] not in [0, 1]: + raise ValidationError(VALIDATION_MSG.format("compliance_int", "0 or 1", compliance_details["compliance_int"])) + for val in ["missing", "extra"]: + if not isinstance(compliance_details[val], str) and not _is_jsonable(compliance_details[val]): + raise ValidationError(VALIDATION_MSG.format(val, "String or Json", compliance_details[val])) + + +def _get_hierconfig_remediation(obj): + """Returns the remediating config.""" + hierconfig_os = obj.device.platform.network_driver_mappings["hier_config"] + if not hierconfig_os: + raise ValidationError(f"platform {obj.network_driver} is not supported by hierconfig.") + + try: + remediation_setting_obj = RemediationSetting.objects.get(platform=obj.rule.platform) + except Exception as err: # pylint: disable=broad-except: + raise ValidationError(f"Platform {obj.network_driver} has no Remediation Settings defined.") from err + + remediation_options = remediation_setting_obj.remediation_options + + try: + hc_kwargs = {"hostname": obj.device.name, "os": hierconfig_os} + if remediation_options: + hc_kwargs.update(hconfig_options=remediation_options) + host = HierConfigHost(**hc_kwargs) + + except Exception as err: # pylint: disable=broad-except: + raise Exception( # pylint: disable=broad-exception-raised + f"Cannot instantiate HierConfig on {obj.device.name}, check Device, Platform and Hier Options." + ) from err + + host.load_generated_config(obj.intended) + host.load_running_config(obj.actual) + host.remediation_config() + remediation_config = host.remediation_config_filtered_text(include_tags={}, exclude_tags={}) + + return remediation_config + + +# The below maps the provided compliance types +FUNC_MAPPER = { + ComplianceRuleConfigTypeChoice.TYPE_CLI: _get_cli_compliance, + ComplianceRuleConfigTypeChoice.TYPE_JSON: _get_json_compliance, + ComplianceRuleConfigTypeChoice.TYPE_XML: _get_xml_compliance, + RemediationTypeChoice.TYPE_HIERCONFIG: _get_hierconfig_remediation, +} +# The below conditionally add the custom provided compliance type +for custom_function, custom_type in CUSTOM_FUNCTIONS.items(): + if PLUGIN_CFG.get(custom_function): + try: + FUNC_MAPPER[custom_type] = import_string(PLUGIN_CFG[custom_function]) + except Exception as error: # pylint: disable=broad-except + msg = ( + "There was an issue attempting to import the custom function of" + f"{PLUGIN_CFG[custom_function]}, this is expected with a local configuration issue " + "and not related to the Golden Configuration App, please contact your system admin for further details" + ) + raise Exception(msg).with_traceback(error.__traceback__) + + +@extras_features( + "custom_fields", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) class ComplianceFeature(PrimaryModel): # pylint: disable=too-many-ancestors - """Base model for Golden Config app.""" + """ComplianceFeature details.""" name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(max_length=100, unique=True) description = models.CharField(max_length=200, blank=True) - # additional model fields class Meta: - """Meta class.""" + """Meta information for ComplianceFeature model.""" + + ordering = ("slug",) + + def __str__(self): + """Return a sane string representation of the instance.""" + return self.slug + + +@extras_features( + "custom_fields", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class ComplianceRule(PrimaryModel): # pylint: disable=too-many-ancestors + """ComplianceRule details.""" + + feature = models.ForeignKey(to="ComplianceFeature", on_delete=models.CASCADE, related_name="feature") + + platform = models.ForeignKey( + to="dcim.Platform", + on_delete=models.CASCADE, + related_name="compliance_rules", + ) + description = models.CharField( + max_length=200, + blank=True, + ) + config_ordered = models.BooleanField( + verbose_name="Configured Ordered", + help_text="Whether or not the configuration order matters, such as in ACLs.", + default=False, + ) + + config_remediation = models.BooleanField( + default=False, + verbose_name="Config Remediation", + help_text="Whether or not the config remediation is executed for this compliance rule.", + ) + + match_config = models.TextField( + blank=True, + verbose_name="Config to Match", + help_text="The config to match that is matched based on the parent most configuration. E.g.: For CLI `router bgp` or `ntp`. For JSON this is a top level key name. For XML this is a xpath query.", + ) + config_type = models.CharField( + max_length=20, + default=ComplianceRuleConfigTypeChoice.TYPE_CLI, + choices=ComplianceRuleConfigTypeChoice, + help_text="Whether the configuration is in CLI, JSON, or XML format.", + ) + custom_compliance = models.BooleanField( + default=False, help_text="Whether this Compliance Rule is proceeded as custom." + ) + + @property + def remediation_setting(self): + """Returns remediation settings for a particular platform.""" + return RemediationSetting.objects.filter(platform=self.platform).first() + + class Meta: + """Meta information for ComplianceRule model.""" + + ordering = ("platform", "feature__name") + unique_together = ( + "feature", + "platform", + ) + + def __str__(self): + """Return a sane string representation of the instance.""" + return f"{self.platform} - {self.feature.name}" + + def clean(self): + """Verify that if cli, then match_config is set.""" + if self.config_type == ComplianceRuleConfigTypeChoice.TYPE_CLI and not self.match_config: + raise ValidationError("CLI configuration set, but no configuration set to match.") + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class ConfigCompliance(PrimaryModel): # pylint: disable=too-many-ancestors + """Configuration compliance details.""" + + device = models.ForeignKey(to="dcim.Device", on_delete=models.CASCADE, help_text="The device") + rule = models.ForeignKey(to="ComplianceRule", on_delete=models.CASCADE, related_name="rule") + compliance = models.BooleanField(blank=True) + actual = models.JSONField(blank=True, help_text="Actual Configuration for feature") + intended = models.JSONField(blank=True, help_text="Intended Configuration for feature") + # these three are config snippets exposed for the ConfigDeployment. + remediation = models.JSONField(blank=True, help_text="Remediation Configuration for the device") + missing = models.JSONField(blank=True, help_text="Configuration that should be on the device.") + extra = models.JSONField(blank=True, help_text="Configuration that should not be on the device.") + ordered = models.BooleanField(default=False) + # Used for django-pivot, both compliance and compliance_int should be set. + compliance_int = models.IntegerField(blank=True) - ordering = ["name"] + def to_objectchange(self, action, *, related_object=None, object_data_extra=None, object_data_exclude=None): # pylint: disable=arguments-differ + """Remove actual and intended configuration from changelog.""" + fields_to_exclude = ["actual", "intended"] + if not object_data_exclude: + object_data_exclude = fields_to_exclude + data_v2 = serialize_object_v2(self) + for field in fields_to_exclude: + data_v2.pop(field) + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self, extra=object_data_extra, exclude=object_data_exclude), + object_data_v2=data_v2, + related_object=related_object, + ) - # Option for fixing capitalization (i.e. "Snmp" vs "SNMP") - # verbose_name = "Golden Config" + class Meta: + """Set unique together fields for model.""" - # Option for fixing plural name (i.e. "Chicken Tenders" vs "Chicken Tendies") - # verbose_name_plural = "Golden Configs" + ordering = ["device", "rule"] + unique_together = ("device", "rule") def __str__(self): - """Stringify instance.""" + """String representation of a the compliance.""" + return f"{self.device} -> {self.rule} -> {self.compliance}" + + def compliance_on_save(self): + """The actual configuration compliance happens here, but the details for actual compliance job would be found in FUNC_MAPPER.""" + if self.rule.custom_compliance: + if not FUNC_MAPPER.get("custom"): + raise ValidationError( + "Custom type provided, but no `get_custom_compliance` config set, please contact system admin." + ) + compliance_details = FUNC_MAPPER["custom"](obj=self) + _verify_get_custom_compliance_data(compliance_details) + else: + compliance_details = FUNC_MAPPER[self.rule.config_type](obj=self) + + self.compliance = compliance_details["compliance"] + self.compliance_int = compliance_details["compliance_int"] + self.ordered = compliance_details["ordered"] + self.missing = compliance_details["missing"] + self.extra = compliance_details["extra"] + + def remediation_on_save(self): + """The actual remediation happens here, before saving the object.""" + if self.compliance: + self.remediation = "" + return + + if not self.rule.config_remediation: + self.remediation = "" + return + + if not self.rule.remediation_setting: + self.remediation = "" + return + + remediation_config = FUNC_MAPPER[self.rule.remediation_setting.remediation_type](obj=self) + self.remediation = remediation_config + + def save(self, *args, **kwargs): + """The actual configuration compliance happens here, but the details for actual compliance job would be found in FUNC_MAPPER.""" + self.compliance_on_save() + self.remediation_on_save() + self.full_clean() + + # This accounts for django 4.2 `Setting update_fields in Model.save() may now be required` change + # in behavior + if kwargs.get("update_fields"): + kwargs["update_fields"].update( + {"compliance", "compliance_int", "ordered", "missing", "extra", "remediation"} + ) + + super().save(*args, **kwargs) + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class GoldenConfig(PrimaryModel): # pylint: disable=too-many-ancestors + """Configuration Management Model.""" + + device = models.OneToOneField( + to="dcim.Device", + on_delete=models.CASCADE, + help_text="device", + blank=False, + ) + backup_config = models.TextField(blank=True, help_text="Full backup config for device.") + backup_last_attempt_date = models.DateTimeField(null=True, blank=True) + backup_last_success_date = models.DateTimeField(null=True, blank=True) + + intended_config = models.TextField(blank=True, help_text="Intended config for the device.") + intended_last_attempt_date = models.DateTimeField(null=True, blank=True) + intended_last_success_date = models.DateTimeField(null=True, blank=True) + + compliance_config = models.TextField(blank=True, help_text="Full config diff for device.") + compliance_last_attempt_date = models.DateTimeField(null=True, blank=True) + compliance_last_success_date = models.DateTimeField(null=True, blank=True) + + def to_objectchange(self, action, *, related_object=None, object_data_extra=None, object_data_exclude=None): # pylint: disable=arguments-differ + """Remove actual and intended configuration from changelog.""" + fields_to_exclude = ["backup_config", "intended_config", "compliance_config"] + if not object_data_exclude: + object_data_exclude = fields_to_exclude + data_v2 = serialize_object_v2(self) + for field in fields_to_exclude: + data_v2.pop(field) + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + object_data=serialize_object(self, extra=object_data_extra, exclude=object_data_exclude), + object_data_v2=data_v2, + related_object=related_object, + ) + + @staticmethod + def get_dynamic_group_device_pks(): + """Get all Device PKs associated with GoldenConfigSetting DynamicGroups.""" + gc_dynamic_group_device_queryset = Device.objects.none() + for setting in GoldenConfigSetting.objects.all(): + # using "|" should not require calling distinct afterwards + gc_dynamic_group_device_queryset = gc_dynamic_group_device_queryset | setting.dynamic_group.members + + return set(gc_dynamic_group_device_queryset.values_list("pk", flat=True)) + + @classmethod + def get_golden_config_device_ids(cls): + """Get all Device PKs associated with GoldenConfig entries.""" + return set(cls.objects.values_list("device__pk", flat=True)) + + class Meta: + """Set unique together fields for model.""" + + ordering = ["device"] + + def __str__(self): + """String representation of a the compliance.""" + return f"{self.device}" + + +class GoldenConfigSettingManager(BaseManager.from_queryset(RestrictedQuerySet)): + """Manager for GoldenConfigSetting.""" + + def get_for_device(self, device): + """Return the highest weighted GoldenConfigSetting assigned to a device.""" + if not isinstance(device, Device): + raise ValueError("The device argument must be a Device instance.") + dynamic_group = device.dynamic_groups.exclude(golden_config_setting__isnull=True) + if dynamic_group.exists(): + return dynamic_group.order_by("-golden_config_setting__weight").first().golden_config_setting + return None + + +@extras_features( + "graphql", +) +class GoldenConfigSetting(PrimaryModel): # pylint: disable=too-many-ancestors + """GoldenConfigSetting Model definition. This provides global configs instead of via configs.py.""" + + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(max_length=100, unique=True) + weight = models.PositiveSmallIntegerField(default=1000) + description = models.CharField( + max_length=200, + blank=True, + ) + backup_repository = models.ForeignKey( + to="extras.GitRepository", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="backup_repository", + limit_choices_to={"provided_contents__contains": "nautobot_golden_config.backupconfigs"}, + ) + backup_path_template = models.CharField( + max_length=255, + blank=True, + verbose_name="Backup Path in Jinja Template Form", + help_text="The Jinja path representation of where the backup file will be found. The variable `obj` is available as the device instance object of a given device, as is the case for all Jinja templates. e.g. `{{obj.location.name|slugify}}/{{obj.name}}.cfg`", + ) + intended_repository = models.ForeignKey( + to="extras.GitRepository", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="intended_repository", + limit_choices_to={"provided_contents__contains": "nautobot_golden_config.intendedconfigs"}, + ) + intended_path_template = models.CharField( + max_length=255, + blank=True, + verbose_name="Intended Path in Jinja Template Form", + help_text="The Jinja path representation of where the generated file will be placed. e.g. `{{obj.location.name|slugify}}/{{obj.name}}.cfg`", + ) + jinja_repository = models.ForeignKey( + to="extras.GitRepository", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="jinja_template", + limit_choices_to={"provided_contents__contains": "nautobot_golden_config.jinjatemplate"}, + ) + jinja_path_template = models.CharField( + max_length=255, + blank=True, + verbose_name="Template Path in Jinja Template Form", + help_text="The Jinja path representation of where the Jinja template can be found. e.g. `{{obj.platform.network_driver}}.j2`", + ) + backup_test_connectivity = models.BooleanField( + default=True, + verbose_name="Backup Test", + help_text="Whether or not to pretest the connectivity of the device by verifying there is a resolvable IP that can connect to port 22.", + ) + sot_agg_query = models.ForeignKey( + to="extras.GraphQLQuery", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="sot_aggregation", + ) + dynamic_group = models.OneToOneField( + to="extras.DynamicGroup", + on_delete=models.PROTECT, + related_name="golden_config_setting", + ) + is_dynamic_group_associable_model = False + + objects = GoldenConfigSettingManager() + + def __str__(self): + """Return a simple string if model is called.""" + return f"Golden Config Setting - {self.name}" + + class Meta: + """Set unique fields for model. + + Provide ordering used in tables and get_device_to_settings_map. + Sorting on weight is performed from the highest weight value to the lowest weight value. + This is to ensure only one app settings could be applied per single device based on priority and name. + """ + + verbose_name = "Golden Config Setting" + ordering = ["-weight", "name"] # Refer to weight comment in class docstring. + + def clean(self): + """Validate the scope and GraphQL query.""" + super().clean() + + if ENABLE_SOTAGG and not self.sot_agg_query: + raise ValidationError("A GraphQL query must be defined when `ENABLE_SOTAGG` is True") + + if self.sot_agg_query: + LOGGER.debug("GraphQL - test query start with: `%s`", GRAPHQL_STR_START) + if not str(self.sot_agg_query.query.lstrip()).startswith(GRAPHQL_STR_START): + raise ValidationError(f"The GraphQL query must start with exactly `{GRAPHQL_STR_START}`") + + def get_queryset(self): + """Generate a Device QuerySet from the filter.""" + return self.dynamic_group.members + + def device_count(self): + """Return the number of devices in the group.""" + return self.dynamic_group.count + + def get_url_to_filtered_device_list(self): + """Get url to all devices that are matching the filter.""" + return self.dynamic_group.get_group_members_url() + + def get_jinja_template_path_for_device(self, device): + """Get the Jinja template path for a device.""" + if self.jinja_repository is not None: + rendered_path = render_jinja2(template_code=self.jinja_path_template, context={"obj": device}) + return f"{self.jinja_repository.filesystem_path}{os.path.sep}{rendered_path}" + return None + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class ConfigRemove(PrimaryModel): # pylint: disable=too-many-ancestors + """ConfigRemove for Regex Line Removals from Backup Configuration Model definition.""" + + name = models.CharField(max_length=255) + platform = models.ForeignKey( + to="dcim.Platform", + on_delete=models.CASCADE, + related_name="backup_line_remove", + ) + description = models.CharField( + max_length=200, + blank=True, + ) + regex = models.CharField( + max_length=200, + verbose_name="Regex Pattern", + help_text="Regex pattern used to remove a line from the backup configuration.", + ) + + clone_fields = ["platform", "description", "regex"] + + class Meta: + """Meta information for ConfigRemove model.""" + + ordering = ("platform", "name") + unique_together = ("name", "platform") + + def __str__(self): + """Return a simple string if model is called.""" return self.name + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class ConfigReplace(PrimaryModel): # pylint: disable=too-many-ancestors + """ConfigReplace for Regex Line Replacements from Backup Configuration Model definition.""" + + name = models.CharField(max_length=255) + platform = models.ForeignKey( + to="dcim.Platform", + on_delete=models.CASCADE, + related_name="backup_line_replace", + ) + description = models.CharField( + max_length=200, + blank=True, + ) + regex = models.CharField( + max_length=200, + verbose_name="Regex Pattern to Substitute", + help_text="Regex pattern that will be found and replaced with 'replaced text'.", + ) + replace = models.CharField( + max_length=200, + verbose_name="Replaced Text", + help_text="Text that will be inserted in place of Regex pattern match.", + ) + + clone_fields = ["platform", "description", "regex", "replace"] + + class Meta: + """Meta information for ConfigReplace model.""" + + ordering = ("platform", "name") + unique_together = ("name", "platform") + + def __str__(self): + """Return a simple string if model is called.""" + return self.name + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class RemediationSetting(PrimaryModel): # pylint: disable=too-many-ancestors + """RemediationSetting details.""" + + # Remediation points to the platform + platform = models.OneToOneField( + to="dcim.Platform", + on_delete=models.CASCADE, + related_name="remediation_settings", + ) + + remediation_type = models.CharField( + max_length=50, + default=RemediationTypeChoice.TYPE_HIERCONFIG, + choices=RemediationTypeChoice, + help_text="Whether the remediation setting is type HierConfig or custom.", + ) + + # takes options.json. + remediation_options = models.JSONField( + blank=True, + default=dict, + help_text="Remediation Configuration for the device", + ) + + csv_headers = [ + "platform", + "remediation_type", + ] + + class Meta: + """Meta information for RemediationSettings model.""" + + ordering = ("platform", "remediation_type") + + def to_csv(self): + """Indicates model fields to return as csv.""" + return ( + self.platform, + self.remediation_type, + ) + + def __str__(self): + """Return a sane string representation of the instance.""" + return str(self.platform) + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", + "statuses", +) +class ConfigPlan(PrimaryModel): # pylint: disable=too-many-ancestors + """ConfigPlan for Golden Configuration Plan Model definition.""" + + plan_type = models.CharField(max_length=20, choices=ConfigPlanTypeChoice, verbose_name="Plan Type") + device = models.ForeignKey( + to="dcim.Device", + on_delete=models.CASCADE, + related_name="config_plan", + ) + config_set = models.TextField(help_text="Configuration set to be applied to device.") + feature = models.ManyToManyField( + to=ComplianceFeature, + related_name="config_plan", + blank=True, + ) + plan_result = models.ForeignKey( + to="extras.JobResult", + on_delete=models.CASCADE, + related_name="config_plan", + verbose_name="Plan Result", + ) + deploy_result = models.ForeignKey( + to="extras.JobResult", + on_delete=models.PROTECT, + related_name="config_plan_deploy_result", + verbose_name="Deploy Result", + blank=True, + null=True, + ) + change_control_id = models.CharField( + max_length=50, + blank=True, + verbose_name="Change Control ID", + help_text="Change Control ID for this configuration plan.", + ) + change_control_url = models.URLField(blank=True, verbose_name="Change Control URL") + status = StatusField(blank=True, null=True, on_delete=models.PROTECT) + + class Meta: + """Meta information for ConfigPlan model.""" + + ordering = ("-created", "device") + unique_together = ( + "plan_type", + "device", + "created", + ) + + def __str__(self): + """Return a simple string if model is called.""" + return f"{self.device.name}-{self.plan_type}-{self.created}" diff --git a/nautobot_golden_config/navigation.py b/nautobot_golden_config/navigation.py index e28f24c4..0b3aa46d 100644 --- a/nautobot_golden_config/navigation.py +++ b/nautobot_golden_config/navigation.py @@ -1,24 +1,162 @@ -"""Menu items.""" +"""Add the configuration compliance buttons to the Apps Navigation.""" from nautobot.apps.ui import NavMenuAddButton, NavMenuGroup, NavMenuItem, NavMenuTab -items = ( +from nautobot_golden_config.utilities.constant import ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_PLAN + +items_operate = [ NavMenuItem( - link="plugins:nautobot_golden_config:compliancefeature_list", - name="Golden Config", - permissions=["nautobot_golden_config.view_compliancefeature"], + link="plugins:nautobot_golden_config:goldenconfig_list", + name="Config Overview", + permissions=["nautobot_golden_config.view_goldenconfig"], + ) +] + +items_setup = [] + +if ENABLE_COMPLIANCE: + items_operate.append( + NavMenuItem( + link="plugins:nautobot_golden_config:configcompliance_list", + name="Config Compliance", + permissions=["nautobot_golden_config.view_configcompliance"], + ) + ) + +if ENABLE_COMPLIANCE: + items_setup.append( + NavMenuItem( + link="plugins:nautobot_golden_config:compliancerule_list", + name="Compliance Rules", + permissions=["nautobot_golden_config.view_compliancerule"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:compliancerule_add", + permissions=["nautobot_golden_config.add_compliancerule"], + ), + ), + ) + ) + +if ENABLE_COMPLIANCE: + items_setup.append( + NavMenuItem( + link="plugins:nautobot_golden_config:compliancefeature_list", + name="Compliance Features", + permissions=["nautobot_golden_config.view_compliancefeature"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:compliancefeature_add", + permissions=["nautobot_golden_config.add_compliancefeature"], + ), + ), + ) + ) + + +if ENABLE_COMPLIANCE: + items_operate.append( + NavMenuItem( + link="plugins:nautobot_golden_config:configcompliance_overview", + name="Compliance Report", + permissions=["nautobot_golden_config.view_configcompliance"], + ) + ) + +if ENABLE_PLAN: + items_operate.append( + NavMenuItem( + link="plugins:nautobot_golden_config:configplan_list", + name="Config Plans", + permissions=["nautobot_golden_config.view_configplan"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:configplan_add", + permissions=["nautobot_golden_config.add_configplan"], + ), + ), + ) + ) + +if ENABLE_BACKUP: + items_setup.append( + NavMenuItem( + link="plugins:nautobot_golden_config:configremove_list", + name="Config Removals", + permissions=["nautobot_golden_config.view_configremove"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:configremove_add", + permissions=["nautobot_golden_config.add_configremove"], + ), + ), + ) + ) + +if ENABLE_BACKUP: + items_setup.append( + NavMenuItem( + link="plugins:nautobot_golden_config:configreplace_list", + name="Config Replacements", + permissions=["nautobot_golden_config.view_configreplace"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:configreplace_add", + permissions=["nautobot_golden_config.add_configreplace"], + ), + ), + ) + ) + + +if ENABLE_COMPLIANCE: + items_setup.append( + NavMenuItem( + link="plugins:nautobot_golden_config:remediationsetting_list", + name="Remediation Settings", + permissions=["nautobot_golden_config.view_remediationsetting"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_golden_config:remediationsetting_add", + permissions=["nautobot_golden_config.add_remediationsetting"], + ), + ), + ) + ) + +items_setup.append( + NavMenuItem( + link="plugins:nautobot_golden_config:goldenconfigsetting_list", + name="Golden Config Settings", + permissions=["nautobot_golden_config.view_goldenconfigsetting"], buttons=( NavMenuAddButton( - link="plugins:nautobot_golden_config:compliancefeature_add", - permissions=["nautobot_golden_config.add_compliancefeature"], + link="plugins:nautobot_golden_config:goldenconfigsetting_add", + permissions=["nautobot_golden_config.change_goldenconfigsetting"], ), ), ), ) + menu_items = ( NavMenuTab( - name="Apps", - groups=(NavMenuGroup(name="Golden Config", items=tuple(items)),), + name="Golden Config", + weight=1000, + groups=( + NavMenuGroup(name="Manage", weight=100, items=tuple(items_operate)), + NavMenuGroup(name="Setup", weight=100, items=tuple(items_setup)), + NavMenuGroup( + name="Tools", + weight=300, + items=( + NavMenuItem( + link="plugins:nautobot_golden_config:generate_intended_config", + name="Generate Intended Config", + permissions=["dcim.view_device", "extras.view_gitrepository"], + ), + ), + ), + ), ), ) diff --git a/nautobot_golden_config/tables.py b/nautobot_golden_config/tables.py index e662cb33..57adbaaf 100644 --- a/nautobot_golden_config/tables.py +++ b/nautobot_golden_config/tables.py @@ -1,38 +1,532 @@ -"""Tables for nautobot_golden_config.""" +"""Django Tables2 classes for golden_config app.""" -import django_tables2 as tables -from nautobot.apps.tables import BaseTable, ButtonsColumn, ToggleColumn +import copy + +from django.utils.html import format_html +from django_tables2 import Column, LinkColumn, TemplateColumn +from django_tables2.utils import A +from nautobot.apps.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn +from nautobot.extras.tables import StatusTableMixin from nautobot_golden_config import models +from nautobot_golden_config.utilities.constant import CONFIG_FEATURES, ENABLE_BACKUP, ENABLE_COMPLIANCE, ENABLE_INTENDED + +ALL_ACTIONS = """ +{% if backup == True %} + {% if record.config_type == 'json' %} + + {% else %} + {% if record.backup_config %} + + + + {% else %} + + {% endif %} + {% endif %} +{% endif %} +{% if intended == True %} + {% if record.config_type == 'json' %} + + {% else %} + {% if record.intended_config %} + + + + {% else %} + + {% endif %} + {% endif %} +{% endif %} +{% if postprocessing == True %} + {% if record.intended_config %} + + + + {% else %} + + {% endif %} +{% endif %} +{% if compliance == True %} + {% if record.intended_config and record.backup_config %} + + + + {% else %} + + {% endif %} +{% endif %} +{% if sotagg == True %} + + + + {% if record.config_type == 'json' %} + + {% else %} + + + + + + {% endif %} +{% endif %} +""" + +CONFIG_SET_BUTTON = """ + + + + +""" + +MATCH_CONFIG = """{{ record.match_config|linebreaksbr }}""" + + +def actual_fields(): + """Convienance function to conditionally toggle columns.""" + active_fields = ["pk", "name"] + if ENABLE_BACKUP: + active_fields.append("backup_last_success_date") + if ENABLE_INTENDED: + active_fields.append("intended_last_success_date") + if ENABLE_COMPLIANCE: + active_fields.append("compliance_last_success_date") + active_fields.append("actions") + return tuple(active_fields) + + +# +# Columns +# + + +class PercentageColumn(Column): + """Column used to display percentage.""" + + def render(self, value): + """Render percentage value.""" + return f"{value} %" + + +class ComplianceColumn(Column): + """Column used to display config compliance status (True/False/None).""" + + def render(self, value): + """Render an entry in this column.""" + if value == 1: # pylint: disable=no-else-return + return format_html('') + elif value == 0: + return format_html('') + else: # value is None + return format_html('') + + +# +# Tables +# + + +# ConfigCompliance +class ConfigComplianceTable(BaseTable): + """Table for rendering a listing of Device entries and their associated ConfigCompliance record status.""" + + pk = ToggleColumn(accessor=A("device")) + device = TemplateColumn( + template_code="""{{ record.device__name }} """ + ) + + def __init__(self, *args, **kwargs): + """Override default values to dynamically add columns.""" + # Used ConfigCompliance.objects on purpose, vs queryset (set in args[0]), as there were issues with that as + # well as not as expected from user standpoint (e.g. not always the same values on columns depending on + # filtering) + features = list( + models.ConfigCompliance.objects.order_by("rule__feature__slug") + .values_list("rule__feature__slug", flat=True) + .distinct() + ) + extra_columns = [(feature, ComplianceColumn(verbose_name=feature)) for feature in features] + kwargs["extra_columns"] = extra_columns + # Nautobot's BaseTable.configurable_columns() only recognizes columns in self.base_columns, + # so override the class's base_columns to include our additional columns as configurable. + self.base_columns = copy.deepcopy(self.base_columns) + for feature, column in extra_columns: + self.base_columns[feature] = column + super().__init__(*args, **kwargs) + + class Meta(BaseTable.Meta): + """Metaclass attributes of ConfigComplianceTable.""" + + model = models.ConfigCompliance + fields = ( + "pk", + "device", + ) + # All other fields (ConfigCompliance names) are constructed dynamically at instantiation time - see views.py + + +class ConfigComplianceGlobalFeatureTable(BaseTable): # pylint: disable=nb-sub-class-name + """Table for feature compliance report.""" + + name = Column(accessor="rule__feature__slug", verbose_name="Feature") + count = Column(accessor="count", verbose_name="Total") + compliant = Column(accessor="compliant", verbose_name="Compliant") + non_compliant = Column(accessor="non_compliant", verbose_name="Non-Compliant") + comp_percent = PercentageColumn(accessor="comp_percent", verbose_name="Compliance (%)") + + class Meta(BaseTable.Meta): + """Metaclass attributes of ConfigComplianceGlobalFeatureTable.""" + + model = models.ConfigCompliance + fields = ["name", "count", "compliant", "non_compliant", "comp_percent"] + default_columns = [ + "name", + "count", + "compliant", + "non_compliant", + "comp_percent", + ] + + +class ConfigComplianceDeleteTable(BaseTable): # pylint: disable=nb-sub-class-name + """Table for device compliance report.""" + + feature = Column(accessor="rule__feature__name", verbose_name="Feature") + + class Meta(BaseTable.Meta): + """Metaclass attributes of ConfigComplianceDeleteTable.""" + + device = Column(accessor="device__name", verbose_name="Device Name") + model = models.ConfigCompliance + fields = ("device", "feature") + + +class DeleteGoldenConfigTable(BaseTable): # pylint: disable=nb-sub-class-name + """ + Table used in bulk delete confirmation. + + This is required since there model is different when deleting the record compared to when viewing the records initially via Device. + """ + + pk = ToggleColumn() + + def __init__(self, *args, **kwargs): + """Remove all fields from showing except device .""" + super().__init__(*args, **kwargs) + for feature in list(self.base_columns.keys()): # pylint: disable=no-member + if feature not in ["pk", "device"]: + self.base_columns.pop(feature) # pylint: disable=no-member + self.sequence.remove(feature) + + class Meta(BaseTable.Meta): + """Meta for class DeleteGoldenConfigTable.""" + + model = models.GoldenConfig + + +# GoldenConfig + + +class GoldenConfigTable(BaseTable): + """Table to display Config Management Status.""" + + pk = ToggleColumn() + name = LinkColumn( + "plugins:nautobot_golden_config:goldenconfig", + args=[A("pk")], + text=lambda record: record.device.name, + verbose_name="Device", + ) + + if ENABLE_BACKUP: + backup_last_success_date = Column( + verbose_name="Backup Status", empty_values=(), order_by="backup_last_success_date" + ) + if ENABLE_INTENDED: + intended_last_success_date = Column( + verbose_name="Intended Status", + empty_values=(), + order_by="intended_last_success_date", + ) + if ENABLE_COMPLIANCE: + compliance_last_success_date = Column( + verbose_name="Compliance Status", + empty_values=(), + order_by="compliance_last_success_date", + ) + + actions = TemplateColumn( + template_code=ALL_ACTIONS, verbose_name="Actions", extra_context=CONFIG_FEATURES, orderable=False + ) + + def _render_last_success_date(self, record, column, value): + """Abstract method to get last success per row record.""" + last_success_date = getattr(record, f"{value}_last_success_date", None) + last_attempt_date = getattr(record, f"{value}_last_attempt_date", None) + if not last_success_date or not last_attempt_date: + column.attrs = {"td": {"style": "color:black"}} + return "--" + if not last_success_date and not last_attempt_date: + column.attrs = {"td": {"style": "color:black"}} + return "--" + if last_success_date and last_attempt_date == last_success_date: + column.attrs = {"td": {"style": "color:green"}} + return last_success_date + column.attrs = {"td": {"style": "color:red"}} + return last_success_date + + def render_backup_last_success_date(self, record, column): + """Pull back backup last success per row record.""" + return self._render_last_success_date(record, column, "backup") + + def render_intended_last_success_date(self, record, column): + """Pull back intended last success per row record.""" + return self._render_last_success_date(record, column, "intended") + + def render_compliance_last_success_date(self, record, column): + """Pull back compliance last success per row record.""" + return self._render_last_success_date(record, column, "compliance") + + class Meta(BaseTable.Meta): + """Meta for class GoldenConfigTable.""" + + model = models.GoldenConfig + fields = actual_fields() + + +# ComplianceFeature class ComplianceFeatureTable(BaseTable): + """Table to display Compliance Features.""" + + pk = ToggleColumn() + name = LinkColumn("plugins:nautobot_golden_config:compliancefeature", args=[A("pk")]) + + class Meta(BaseTable.Meta): + """Table to display Compliance Features Meta Data.""" + + model = models.ComplianceFeature + fields = ("pk", "name", "slug", "description") + default_columns = ("pk", "name", "slug", "description") + + +# ComplianceRule + + +class ComplianceRuleTable(BaseTable): + """Table to display Compliance Rules.""" + + pk = ToggleColumn() + feature = LinkColumn("plugins:nautobot_golden_config:compliancerule", args=[A("pk")]) + match_config = TemplateColumn(template_code=MATCH_CONFIG) + config_ordered = BooleanColumn() + custom_compliance = BooleanColumn() + config_remediation = BooleanColumn() + + class Meta(BaseTable.Meta): + """Table to display Compliance Rules Meta Data.""" + + model = models.ComplianceRule + fields = ( + "pk", + "feature", + "platform", + "description", + "config_ordered", + "match_config", + "config_type", + "custom_compliance", + "config_remediation", + ) + default_columns = ( + "pk", + "feature", + "platform", + "description", + "config_ordered", + "match_config", + "config_type", + "custom_compliance", + "config_remediation", + ) + + +# ConfigRemove + + +class ConfigRemoveTable(BaseTable): + """Table to display Compliance Rules.""" + + pk = ToggleColumn() + name = LinkColumn("plugins:nautobot_golden_config:configremove", args=[A("pk")]) + + class Meta(BaseTable.Meta): + """Table to display Compliance Rules Meta Data.""" + + model = models.ConfigRemove + fields = ("pk", "name", "platform", "description", "regex") + default_columns = ("pk", "name", "platform", "description", "regex") + + +# ConfigReplace + + +class ConfigReplaceTable(BaseTable): + """Table to display Compliance Rules.""" + + pk = ToggleColumn() + name = LinkColumn("plugins:nautobot_golden_config:configreplace", args=[A("pk")]) + + class Meta(BaseTable.Meta): + """Table to display Compliance Rules Meta Data.""" + + model = models.ConfigReplace + fields = ("pk", "name", "platform", "description", "regex", "replace") + default_columns = ("pk", "name", "platform", "description", "regex", "replace") + + +class GoldenConfigSettingTable(BaseTable): # pylint: disable=R0903 """Table for list view.""" pk = ToggleColumn() - name = tables.Column(linkify=True) - actions = ButtonsColumn( - models.ComplianceFeature, - # Option for modifying the default action buttons on each row: - # buttons=("changelog", "edit", "delete"), - # Option for modifying the pk for the action buttons: - pk_field="pk", + name = Column(order_by=("_name",), linkify=True) + jinja_repository = Column( + verbose_name="Jinja Repository", + empty_values=(), + ) + intended_repository = Column( + verbose_name="Intended Repository", + empty_values=(), + ) + backup_repository = Column( + verbose_name="Backup Repository", + empty_values=(), ) + def _render_capability(self, record, column, record_attribute): # pylint: disable=unused-argument + if getattr(record, record_attribute, None): + return format_html('') + return format_html('') + + def render_backup_repository(self, record, column): + """Render backup repository boolean value.""" + return self._render_capability(record=record, column=column, record_attribute="backup_repository") + + def render_intended_repository(self, record, column): + """Render intended repository boolean value.""" + return self._render_capability(record=record, column=column, record_attribute="intended_repository") + + def render_jinja_repository(self, record, column): + """Render jinja repository boolean value.""" + return self._render_capability(record=record, column=column, record_attribute="jinja_repository") + class Meta(BaseTable.Meta): """Meta attributes.""" - model = models.ComplianceFeature + model = models.GoldenConfigSetting fields = ( "pk", "name", + "weight", "description", + "backup_repository", + "intended_repository", + "jinja_repository", ) - # Option for modifying the columns that show up in the list view by default: - # default_columns = ( - # "pk", - # "name", - # "description", - # ) + +class RemediationSettingTable(BaseTable): + """Table to display RemediationSetting Rules.""" + + pk = ToggleColumn() + platform = LinkColumn("plugins:nautobot_golden_config:remediationsetting", args=[A("pk")]) + + class Meta(BaseTable.Meta): + """Table to display RemediationSetting Meta Data.""" + + model = models.RemediationSetting + fields = ("pk", "platform", "remediation_type") + default_columns = ("pk", "platform", "remediation_type") + + +# ConfigPlan + + +class ConfigPlanTable(StatusTableMixin, BaseTable): + """Table to display Config Plans.""" + + pk = ToggleColumn() + device = LinkColumn("plugins:nautobot_golden_config:configplan", args=[A("pk")]) + plan_result = TemplateColumn( + template_code="""""" + ) + deploy_result = TemplateColumn( + template_code=""" + {% if record.deploy_result %} + + {% else %} + — + {% endif %} + """ + ) + config_set = TemplateColumn(template_code=CONFIG_SET_BUTTON, verbose_name="Config Set", orderable=False) + tags = TagColumn(url_name="plugins:nautobot_golden_config:configplan_list") + + class Meta(BaseTable.Meta): + """Table to display Config Plans Meta Data.""" + + model = models.ConfigPlan + fields = ( + "pk", + "device", + "created", + "plan_type", + "feature", + "change_control_id", + "change_control_url", + "plan_result", + "deploy_result", + "config_set", + "status", + "tags", + ) + default_columns = ( + "pk", + "device", + "created", + "plan_type", + "feature", + "change_control_id", + "change_control_url", + "plan_result", + "deploy_result", + "config_set", + "status", + ) diff --git a/nautobot_golden_config/templates/nautobot_golden_config/compliancefeature_retrieve.html b/nautobot_golden_config/templates/nautobot_golden_config/compliancefeature_retrieve.html index 8832fd43..72efc811 100644 --- a/nautobot_golden_config/templates/nautobot_golden_config/compliancefeature_retrieve.html +++ b/nautobot_golden_config/templates/nautobot_golden_config/compliancefeature_retrieve.html @@ -1,26 +1,37 @@ - -{% extends 'generic/object_retrieve.html' %} +{% extends 'generic/object_detail.html' %} {% load helpers %} +{% load buttons %} + +{% block buttons %} + {% if perms.nautobot_golden_config.add_compliancefeature %} + {% clone_button object %} + {% endif %} + {% if perms.nautobot_golden_config.change_compliancefeature %} + {% edit_button object key="pk" %} + {% endif %} + {% if perms.nautobot_golden_config.delete_compliancefeature %} + {% delete_button object key="pk" %} + {% endif %} +{% endblock buttons %} {% block content_left_page %}
- ComplianceFeature + Compliance Feature Details
- + + + + + - +
Name - {{ object.name }} - {{ object.name }}
Slug{{ object.slug }}
Description - {{ object.description|placeholder }} - {{ object.description|placeholder }}
-{% endblock content_left_page %} - +{% endblock %} \ No newline at end of file diff --git a/nautobot_golden_config/tests/fixtures.py b/nautobot_golden_config/tests/fixtures.py deleted file mode 100644 index 2e8f570b..00000000 --- a/nautobot_golden_config/tests/fixtures.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Create fixtures for tests.""" - -from nautobot_golden_config.models import ComplianceFeature - - -def create_compliancefeature(): - """Fixture to create necessary number of ComplianceFeature for tests.""" - ComplianceFeature.objects.create(name="Test One") - ComplianceFeature.objects.create(name="Test Two") - ComplianceFeature.objects.create(name="Test Three") diff --git a/nautobot_golden_config/tests/test_api_views.py b/nautobot_golden_config/tests/test_api_views.py deleted file mode 100644 index d6aeed74..00000000 --- a/nautobot_golden_config/tests/test_api_views.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Unit tests for nautobot_golden_config.""" - -from nautobot.apps.testing import APIViewTestCases - -from nautobot_golden_config import models -from nautobot_golden_config.tests import fixtures - - -class ComplianceFeatureAPIViewTest(APIViewTestCases.APIViewTestCase): - # pylint: disable=too-many-ancestors - """Test the API viewsets for ComplianceFeature.""" - - model = models.ComplianceFeature - create_data = [ - { - "name": "Test Model 1", - "description": "test description", - }, - { - "name": "Test Model 2", - }, - ] - bulk_update_data = {"description": "Test Bulk Update"} - - @classmethod - def setUpTestData(cls): - fixtures.create_compliancefeature() diff --git a/nautobot_golden_config/tests/test_filter_compliancefeature.py b/nautobot_golden_config/tests/test_filter_compliancefeature.py deleted file mode 100644 index 55277857..00000000 --- a/nautobot_golden_config/tests/test_filter_compliancefeature.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Test ComplianceFeature Filter.""" - -from django.test import TestCase - -from nautobot_golden_config import filters, models -from nautobot_golden_config.tests import fixtures - - -class ComplianceFeatureFilterTestCase(TestCase): - """ComplianceFeature Filter Test Case.""" - - queryset = models.ComplianceFeature.objects.all() - filterset = filters.ComplianceFeatureFilterSet - - @classmethod - def setUpTestData(cls): - """Setup test data for ComplianceFeature Model.""" - fixtures.create_compliancefeature() - - def test_q_search_name(self): - """Test using Q search with name of ComplianceFeature.""" - params = {"q": "Test One"} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - - def test_q_invalid(self): - """Test using invalid Q search for ComplianceFeature.""" - params = {"q": "test-five"} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) diff --git a/nautobot_golden_config/tests/test_form_compliancefeature.py b/nautobot_golden_config/tests/test_form_compliancefeature.py deleted file mode 100644 index cdeb6daa..00000000 --- a/nautobot_golden_config/tests/test_form_compliancefeature.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Test compliancefeature forms.""" - -from django.test import TestCase - -from nautobot_golden_config import forms - - -class ComplianceFeatureTest(TestCase): - """Test ComplianceFeature forms.""" - - def test_specifying_all_fields_success(self): - form = forms.ComplianceFeatureForm( - data={ - "name": "Development", - "description": "Development Testing", - } - ) - self.assertTrue(form.is_valid()) - self.assertTrue(form.save()) - - def test_specifying_only_required_success(self): - form = forms.ComplianceFeatureForm( - data={ - "name": "Development", - } - ) - self.assertTrue(form.is_valid()) - self.assertTrue(form.save()) - - def test_validate_name_compliancefeature_is_required(self): - form = forms.ComplianceFeatureForm(data={"description": "Development Testing"}) - self.assertFalse(form.is_valid()) - self.assertIn("This field is required.", form.errors["name"]) diff --git a/nautobot_golden_config/tests/test_model_compliancefeature.py b/nautobot_golden_config/tests/test_model_compliancefeature.py deleted file mode 100644 index 819b5264..00000000 --- a/nautobot_golden_config/tests/test_model_compliancefeature.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Test ComplianceFeature.""" - -from django.test import TestCase - -from nautobot_golden_config import models - - -class TestComplianceFeature(TestCase): - """Test ComplianceFeature.""" - - def test_create_compliancefeature_only_required(self): - """Create with only required fields, and validate null description and __str__.""" - compliancefeature = models.ComplianceFeature.objects.create(name="Development") - self.assertEqual(compliancefeature.name, "Development") - self.assertEqual(compliancefeature.description, "") - self.assertEqual(str(compliancefeature), "Development") - - def test_create_compliancefeature_all_fields_success(self): - """Create ComplianceFeature with all fields.""" - compliancefeature = models.ComplianceFeature.objects.create(name="Development", description="Development Test") - self.assertEqual(compliancefeature.name, "Development") - self.assertEqual(compliancefeature.description, "Development Test") diff --git a/nautobot_golden_config/tests/test_views.py b/nautobot_golden_config/tests/test_views.py index 02d5e7aa..937826b7 100644 --- a/nautobot_golden_config/tests/test_views.py +++ b/nautobot_golden_config/tests/test_views.py @@ -1,28 +1,395 @@ -"""Unit tests for views.""" +"""Unit tests for nautobot_golden_config views.""" -from nautobot.apps.testing import ViewTestCases +import datetime +from unittest import mock, skip -from nautobot_golden_config import models -from nautobot_golden_config.tests import fixtures +import nautobot +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.test import RequestFactory, override_settings +from django.urls import reverse +from lxml import html +from nautobot.apps.models import RestrictedQuerySet +from nautobot.apps.testing import TestCase, ViewTestCases +from nautobot.dcim.models import Device +from nautobot.extras.models import Relationship, RelationshipAssociation, Status +from nautobot_golden_config import models, views -class ComplianceFeatureViewTest(ViewTestCases.PrimaryObjectViewTestCase): - # pylint: disable=too-many-ancestors - """Test the ComplianceFeature views.""" +from .conftest import create_device_data, create_feature_rule_json, create_job_result - model = models.ComplianceFeature - bulk_edit_data = {"description": "Bulk edit views"} - form_data = { - "name": "Test 1", - "description": "Initial model", +User = get_user_model() + + +@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"]) +class ConfigComplianceOverviewHelperTestCase(TestCase): + """Test ConfigComplianceOverviewHelper.""" + + @classmethod + def setUpTestData(cls): + """Set up base objects.""" + create_device_data() + dev01 = Device.objects.get(name="Device 1") + dev02 = Device.objects.get(name="Device 2") + dev03 = Device.objects.get(name="Device 3") + dev04 = Device.objects.get(name="Device 4") + + feature_dev01 = create_feature_rule_json(dev01) + feature_dev02 = create_feature_rule_json(dev02) + feature_dev03 = create_feature_rule_json(dev03) + + updates = [ + {"device": dev01, "feature": feature_dev01}, + {"device": dev02, "feature": feature_dev02}, + {"device": dev03, "feature": feature_dev03}, + {"device": dev04, "feature": feature_dev01}, + ] + for update in updates: + models.ConfigCompliance.objects.create( + device=update["device"], + rule=update["feature"], + actual={"foo": {"bar-1": "baz"}}, + intended={"foo": {"bar-2": "baz"}}, + ) + + # TODO: 2.0 turn this back on. + # cls.ccoh = views.ConfigComplianceOverviewOverviewHelper + + def test_plot_visual_no_devices(self): + # TODO: 2.0 turn this back on. + self.assertEqual(True, True) + # aggr = {"comp_percents": 0, "compliants": 0, "non_compliants": 0, "total": 0} + # self.assertEqual(self.ccoh.plot_visual(aggr), None) + + @mock.patch.dict("nautobot_golden_config.tables.CONFIG_FEATURES", {"sotagg": True}) + def test_config_compliance_list_view_with_sotagg_enabled(self): + models.GoldenConfig.objects.create(device=Device.objects.first()) + request = self.client.get("/plugins/golden-config/golden-config/") + self.assertContains(request, '') + + @mock.patch.dict("nautobot_golden_config.tables.CONFIG_FEATURES", {"sotagg": False}) + def test_config_compliance_list_view_with_sotagg_disabled(self): + models.GoldenConfig.objects.create(device=Device.objects.first()) + request = self.client.get("/plugins/golden-config/golden-config/") + self.assertNotContains(request, '') + + @mock.patch.object(views, "graph_ql_query") + @mock.patch.object(views, "get_device_to_settings_map") + @mock.patch("nautobot_golden_config.models.GoldenConfigSetting") + def test_config_compliance_details_sotagg_error( + self, mock_gc_setting, mock_get_device_to_settings_map, mock_graphql_query + ): + device = Device.objects.first() + mock_gc_setting.sot_agg_query = None + mock_get_device_to_settings_map.return_value = {device.id: mock_gc_setting} + request = self.client.get(f"/plugins/golden-config/golden-config/{device.pk}/sotagg/") + expected = "{\n "Error": "No saved `GraphQL Query` query was configured in the `Golden Config Setting`"\n}" + self.assertContains(request, expected) + mock_graphql_query.assert_not_called() + + @mock.patch.object(views, "graph_ql_query") + @mock.patch.object(views, "get_device_to_settings_map") + @mock.patch("nautobot_golden_config.models.GoldenConfigSetting") + def test_config_compliance_details_sotagg_no_error( + self, mock_gc_setting, mock_get_device_to_settings_map, mock_graph_ql_query + ): + device = Device.objects.first() + mock_get_device_to_settings_map.return_value = {device.id: mock_gc_setting} + mock_graph_ql_query.return_value = ("discard value", "This is a mock graphql result") + request = self.client.get(f"/plugins/golden-config/golden-config/{device.pk}/sotagg/") + expected = "This is a mock graphql result" + self.assertContains(request, expected) + mock_graph_ql_query.assert_called() + + +class ConfigReplaceUIViewSetTestCase(ViewTestCases.PrimaryObjectViewTestCase): # pylint: disable=too-many-ancestors + """Test ConfigReplaceUIViewSet.""" + + model = models.ConfigReplace + + bulk_edit_data = { + "description": "new description", } - csv_data = ( - "name", - "Test csv1", - "Test csv2", - "Test csv3", - ) @classmethod def setUpTestData(cls): - fixtures.create_compliancefeature() + """Set up base objects.""" + create_device_data() + platform = Device.objects.first().platform + for num in range(3): + models.ConfigReplace.objects.create( + name=f"test configreplace {num}", + platform=platform, + description="test description", + regex="^(.*)$", + replace="xyz", + ) + cls.form_data = { + "name": "new name", + "platform": platform.pk, + "description": "new description", + "regex": "^NEW (.*)$", + "replace": "NEW replaced text", + } + + # For compatibility with Nautobot lower than v2.2.0 + cls.csv_data = ( + "name,regex,replace,platform", + f"test configreplace 4,^(.*)$,xyz,{platform.pk}", + f"test configreplace 5,^(.*)$,xyz,{platform.pk}", + f"test configreplace 6,^(.*)$,xyz,{platform.pk}", + ) + + +class GoldenConfigListViewTestCase(TestCase): + """Test GoldenConfigListView.""" + + user_permissions = ["nautobot_golden_config.view_goldenconfig", "nautobot_golden_config.change_goldenconfig"] + + @classmethod + def setUpTestData(cls): + """Set up base objects.""" + create_device_data() + cls.gc_settings = models.GoldenConfigSetting.objects.first() + cls.gc_dynamic_group = cls.gc_settings.dynamic_group + cls.gc_dynamic_group.filter = {"name": [dev.name for dev in Device.objects.all()]} + cls.gc_dynamic_group.validated_save() + models.GoldenConfig.objects.create(device=Device.objects.first()) + + def _get_golden_config_table_header(self): + response = self.client.get(f"{self._url}") + html_parsed = html.fromstring(response.content.decode()) + golden_config_table = html_parsed.find_class("table")[0] + return golden_config_table.find("thead") + + @property + def _text_table_headers(self): + if nautobot.__version__ >= "2.3.0": + return ["Device", "Backup Status", "Intended Status", "Compliance Status", "Dynamic Groups", "Actions"] + return ["Device", "Backup Status", "Intended Status", "Compliance Status", "Actions"] + + @property + def _url(self): + return reverse("plugins:nautobot_golden_config:goldenconfig_list") + + def test_page_ok(self): + response = self.client.get(f"{self._url}") + self.assertEqual(response.status_code, 200) + + def test_headers_in_table(self): + table_header = self._get_golden_config_table_header() + headers = table_header.iterdescendants("th") + checkbox_header = next(headers) + checkbox_element = checkbox_header.find("input") + self.assertEqual(checkbox_element.type, "checkbox") + text_headers = [header.text_content() for header in headers] + self.assertEqual(text_headers, self._text_table_headers) + + def test_device_relationship_not_included_in_golden_config_table(self): + # Create a RelationshipAssociation to Device Model to setup test case + device_content_type = ContentType.objects.get_for_model(Device) + platform_content_type = ContentType.objects.get(app_label="dcim", model="platform") + device = Device.objects.first() + relationship = Relationship.objects.create( + label="test platform to dev", + type="one-to-many", + source_type_id=platform_content_type.id, + destination_type_id=device_content_type.id, + ) + RelationshipAssociation.objects.create( + source_type_id=platform_content_type.id, + source_id=device.platform.id, + destination_type_id=device_content_type.id, + destination_id=device.id, + relationship_id=relationship.id, + ) + table_header = self._get_golden_config_table_header() + # xpath expression excludes the pk checkbox column (i.e. the first column) + text_headers = [header.text_content() for header in table_header.xpath("tr/th[position()>1]")] + # This will fail if the Relationships to Device objects showed up in the Golden Config table + self.assertEqual(text_headers, self._text_table_headers) + + @skip("TODO: 2.0 Figure out how do csv tests.") + def test_csv_export(self): + # verify GoldenConfig table is empty + self.assertEqual(models.GoldenConfig.objects.count(), 0) + intended_datetime = datetime.datetime.now() + first_device = self.gc_dynamic_group.members.first() + models.GoldenConfig.objects.create( + device=first_device, + intended_last_attempt_date=intended_datetime, + intended_last_success_date=intended_datetime, + ) + response = self.client.get(f"{self._url}?format=csv") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "text/csv") + csv_data = response.content.decode().splitlines() + csv_headers = "Device Name,backup attempt,backup successful,intended attempt,intended successful,compliance attempt,compliance successful" + self.assertEqual(csv_headers, csv_data[0]) + intended_datetime_formated = intended_datetime.strftime("%Y-%m-%dT%H:%M:%S.%f+00:00") + # Test single entry in GoldenConfig table has data + expected_first_row = f"{first_device.name},,,{intended_datetime_formated},{intended_datetime_formated},," + self.assertEqual(expected_first_row, csv_data[1]) + # Test Devices in scope but without entries in GoldenConfig have empty entries + empty_csv_rows = [ + f"{device.name},,,,,," for device in self.gc_dynamic_group.members.exclude(pk=first_device.pk) + ] + self.assertEqual(empty_csv_rows, csv_data[2:]) + + @skip("TODO: 2.0 Figure out how do csv tests.") + def test_csv_export_with_filter(self): + devices_in_site_1 = Device.objects.filter(site__name="Site 1") + golden_config_devices = self.gc_dynamic_group.members.all() + # Test that there are Devices in GC that are not related to Site 1 + self.assertNotEqual(devices_in_site_1, golden_config_devices) + response = self.client.get(f"{self._url}?site={Device.objects.first().site.slug}&format=csv") + self.assertEqual(response.status_code, 200) + csv_data = response.content.decode().splitlines() + device_names_in_export = [entry.split(",")[0] for entry in csv_data[1:]] + device_names_in_site_1 = [device.name for device in devices_in_site_1] + self.assertEqual(device_names_in_export, device_names_in_site_1) + + +# pylint: disable=too-many-ancestors,too-many-locals +class ConfigPlanTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + # Disabling Create tests because ConfigPlans are created via Job + # ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, +): + """Test ConfigPlan views.""" + + model = models.ConfigPlan + + @classmethod + def setUpTestData(cls): + create_device_data() + device1 = Device.objects.get(name="Device 1") + device2 = Device.objects.get(name="Device 2") + device3 = Device.objects.get(name="Device 3") + + rule1 = create_feature_rule_json(device1, feature="Test Feature 1") + rule2 = create_feature_rule_json(device2, feature="Test Feature 2") + rule3 = create_feature_rule_json(device3, feature="Test Feature 3") + rule4 = create_feature_rule_json(device3, feature="Test Feature 4") + + job_result1 = create_job_result() + job_result2 = create_job_result() + job_result3 = create_job_result() + + not_approved_status = Status.objects.get(name="Not Approved") + approved_status = Status.objects.get(name="Approved") + + plan1 = models.ConfigPlan.objects.create( + device=device1, + plan_type="intended", + config_set="Test Config Set 1", + change_control_id="Test Change Control ID 1", + change_control_url="https://1.example.com/", + status=not_approved_status, + plan_result_id=job_result1.id, + ) + plan1.feature.add(rule1.feature) + plan1.validated_save() + plan2 = models.ConfigPlan.objects.create( + device=device2, + plan_type="missing", + config_set="Test Config Set 2", + change_control_id="Test Change Control ID 2", + change_control_url="https://2.example.com/", + status=not_approved_status, + plan_result_id=job_result2.id, + ) + plan2.feature.add(rule2.feature) + plan2.validated_save() + plan3 = models.ConfigPlan.objects.create( + device=device3, + plan_type="remediation", + config_set="Test Config Set 3", + change_control_id="Test Change Control ID 3", + change_control_url="https://3.example.com/", + status=not_approved_status, + plan_result_id=job_result3.id, + ) + plan3.feature.set([rule3.feature, rule4.feature]) + plan3.validated_save() + + # Used for EditObjectViewTestCase + cls.form_data = { + "change_control_id": "Test Change Control ID 4", + "change_control_url": "https://4.example.com/", + "status": approved_status.pk, + } + + @skip("TODO: 2.0 Figure out how to have pass.") + def test_list_objects_with_constrained_permission(self): + pass + + +@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"]) +class ConfigComplianceUIViewSetTestCase( + ViewTestCases.BulkDeleteObjectsViewTestCase, + # ViewTestCases.ListObjectsViewTestCase, # generic list view tests won't work for this view since the queryset is pivoted +): + """Test ConfigComplianceUIViewSet views.""" + + model = models.ConfigCompliance + + @classmethod + def setUpTestData(cls): + create_device_data() + dev01 = Device.objects.get(name="Device 1") + dev02 = Device.objects.get(name="Device 2") + dev03 = Device.objects.get(name="Device 3") + dev04 = Device.objects.get(name="Device 4") + + for iterator_i in range(4): + feature_dev01 = create_feature_rule_json(dev01, feature=f"TestFeature{iterator_i}") + feature_dev02 = create_feature_rule_json(dev02, feature=f"TestFeature{iterator_i}") + feature_dev03 = create_feature_rule_json(dev03, feature=f"TestFeature{iterator_i}") + + updates = [ + {"device": dev01, "feature": feature_dev01}, + {"device": dev02, "feature": feature_dev02}, + {"device": dev03, "feature": feature_dev03}, + {"device": dev04, "feature": feature_dev01}, + ] + for iterator_j, update in enumerate(updates): + compliance_int = iterator_j % 2 + models.ConfigCompliance.objects.create( + device=update["device"], + rule=update["feature"], + actual={"foo": {"bar-1": "baz"}}, + intended={"foo": {f"bar-{compliance_int}": "baz"}}, + compliance=bool(compliance_int), + compliance_int=compliance_int, + ) + + def test_alter_queryset(self): + """Test alter_queryset method returns the expected pivoted queryset.""" + + unused_features = ( + models.ComplianceFeature.objects.create(slug="unused-feature-1", name="Unused Feature 1"), + models.ComplianceFeature.objects.create(slug="unused-feature-2", name="Unused Feature 2"), + ) + request = RequestFactory(SERVER_NAME="nautobot.example.com").get( + reverse("plugins:nautobot_golden_config:configcompliance_list") + ) + request.user = self.user + queryset = views.ConfigComplianceUIViewSet(request=request).alter_queryset(request) + features = ( + models.ComplianceFeature.objects.filter(feature__rule__isnull=False) + .values_list("slug", flat=True) + .distinct() + ) + self.assertNotIn(unused_features[0].slug, features) + self.assertNotIn(unused_features[1].slug, features) + self.assertGreater(len(features), 0) + self.assertIsInstance(queryset, RestrictedQuerySet) + for device in queryset: + self.assertSequenceEqual(list(device.keys()), ["device", "device__name", *features]) + for feature in features: + self.assertIn(device[feature], [0, 1]) diff --git a/nautobot_golden_config/urls.py b/nautobot_golden_config/urls.py index 22eccbd5..af7a316c 100644 --- a/nautobot_golden_config/urls.py +++ b/nautobot_golden_config/urls.py @@ -5,16 +5,26 @@ from django.views.generic import RedirectView from nautobot.apps.urls import NautobotUIViewSetRouter - from nautobot_golden_config import views +app_name = "nautobot_golden_config" router = NautobotUIViewSetRouter() -router.register("compliancefeature", views.ComplianceFeatureUIViewSet) - +router.register("compliance-feature", views.ComplianceFeatureUIViewSet) +router.register("compliance-rule", views.ComplianceRuleUIViewSet) +router.register("golden-config-setting", views.GoldenConfigSettingUIViewSet) +router.register("config-remove", views.ConfigRemoveUIViewSet) +router.register("config-replace", views.ConfigReplaceUIViewSet) +router.register("remediation-setting", views.RemediationSettingUIViewSet) +router.register("config-plan", views.ConfigPlanUIViewSet) +router.register("config-compliance", views.ConfigComplianceUIViewSet) +router.register("golden-config", views.GoldenConfigUIViewSet) urlpatterns = [ + path("config-compliance/overview/", views.ConfigComplianceOverview.as_view(), name="configcompliance_overview"), + path("config-plan/bulk_deploy/", views.ConfigPlanBulkDeploy.as_view(), name="configplan_bulk-deploy"), + path("generate-intended-config/", views.GenerateIntendedConfigView.as_view(), name="generate_intended_config"), path("docs/", RedirectView.as_view(url=static("nautobot_golden_config/docs/index.html")), name="docs"), ] diff --git a/nautobot_golden_config/views.py b/nautobot_golden_config/views.py index 26a9cd52..e094ad66 100644 --- a/nautobot_golden_config/views.py +++ b/nautobot_golden_config/views.py @@ -1,19 +1,606 @@ -"""Views for nautobot_golden_config.""" +"""Django views for Nautobot Golden Configuration.""" # pylint: disable=too-many-lines -from nautobot.apps.views import NautobotUIViewSet +import json +import logging +from datetime import datetime + +import yaml +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Count, ExpressionWrapper, F, FloatField, Max, Q +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils.html import format_html +from django.utils.timezone import make_aware +from django.views.generic import TemplateView, View +from django_pivot.pivot import pivot +from nautobot.apps import views +from nautobot.core.views import generic +from nautobot.core.views.mixins import PERMISSIONS_ACTION_MAP, ObjectPermissionRequiredMixin +from nautobot.dcim.models import Device +from nautobot.extras.models import Job, JobResult +from rest_framework.decorators import action +from rest_framework.response import Response from nautobot_golden_config import filters, forms, models, tables from nautobot_golden_config.api import serializers +from nautobot_golden_config.utilities import constant +from nautobot_golden_config.utilities.config_postprocessing import get_config_postprocessing +from nautobot_golden_config.utilities.graphql import graph_ql_query +from nautobot_golden_config.utilities.helper import add_message, get_device_to_settings_map +from nautobot_golden_config.utilities.mat_plot import get_global_aggr, plot_barchart_visual, plot_visual + +# TODO: Future #4512 +PERMISSIONS_ACTION_MAP.update( + { + "backup": "view", + "compliance": "view", + "intended": "view", + "sotagg": "view", + "postprocessing": "view", + "devicetab": "view", + } +) +LOGGER = logging.getLogger(__name__) + +# +# GoldenConfig +# + + +class GoldenConfigUIViewSet( # pylint: disable=abstract-method + views.ObjectDetailViewMixin, + views.ObjectDestroyViewMixin, + views.ObjectBulkDestroyViewMixin, + views.ObjectListViewMixin, # TODO: Changing the order of the mixins breaks things... why? +): + """Views for the GoldenConfig model.""" + + bulk_update_form_class = forms.GoldenConfigBulkEditForm + table_class = tables.GoldenConfigTable + filterset_class = filters.GoldenConfigFilterSet + filterset_form_class = forms.GoldenConfigFilterForm + queryset = models.GoldenConfig.objects.all() + serializer_class = serializers.GoldenConfigSerializer + action_buttons = ("export",) + + def __init__(self, *args, **kwargs): + """Used to set default variables on GoldenConfigUIViewSet.""" + super().__init__(*args, **kwargs) + self.device = None + self.output = "" + self.structured_format = None + self.title_name = None + self.is_modal = None + self.config_details = None + self.action_template_name = None + + def filter_queryset(self, queryset): + """Add a warning message when GoldenConfig Table is out of sync.""" + queryset = super().filter_queryset(queryset) + # Only adding a message when no filters are applied + if self.filter_params: + return queryset + + sync_job = Job.objects.get( + module_name="nautobot_golden_config.jobs", job_class_name="SyncGoldenConfigWithDynamicGroups" + ) + sync_job_url = f"{sync_job.name}" + out_of_sync_message = format_html( + "The expected devices and actual devices here are not in sync. " + f"Running the job {sync_job_url} will put it back in sync." + ) + + gc_dynamic_group_device_pks = models.GoldenConfig.get_dynamic_group_device_pks() + gc_device_pks = models.GoldenConfig.get_golden_config_device_ids() + if gc_dynamic_group_device_pks != gc_device_pks: + messages.warning(self.request, message=out_of_sync_message) + + return queryset + + def get_extra_context(self, request, instance=None, **kwargs): + """Get extra context data.""" + context = super().get_extra_context(request, instance) + context["compliance"] = constant.ENABLE_COMPLIANCE + context["backup"] = constant.ENABLE_BACKUP + context["intended"] = constant.ENABLE_INTENDED + jobs = [] + jobs.append(["BackupJob", constant.ENABLE_BACKUP]) + jobs.append(["IntendedJob", constant.ENABLE_INTENDED]) + jobs.append(["ComplianceJob", constant.ENABLE_COMPLIANCE]) + add_message(jobs, request) + return context + + def _pre_helper(self, pk, request): + self.device = Device.objects.get(pk=pk) + self.config_details = models.GoldenConfig.objects.filter(device=self.device).first() + self.action_template_name = "nautobot_golden_config/goldenconfig_details.html" + self.structured_format = "json" + self.is_modal = False + if request.GET.get("modal") == "true": + self.action_template_name = "nautobot_golden_config/goldenconfig_detailsmodal.html" + self.is_modal = True + + def _post_render(self, request): + context = { + "output": self.output, + "device": self.device, + "device_name": self.device.name, + "format": self.structured_format, + "title_name": self.title_name, + "is_modal": self.is_modal, + } + return render(request, self.action_template_name, context) + + @action(detail=True, methods=["get"]) + def backup(self, request, pk, *args, **kwargs): + """Additional action to handle backup_config.""" + self._pre_helper(pk, request) + self.output = self.config_details.backup_config + self.structured_format = "cli" + self.title_name = "Backup Configuration Details" + return self._post_render(request) + + @action(detail=True, methods=["get"]) + def intended(self, request, pk, *args, **kwargs): + """Additional action to handle intended_config.""" + self._pre_helper(pk, request) + self.output = self.config_details.intended_config + self.structured_format = "cli" + self.title_name = "Intended Configuration Details" + return self._post_render(request) + + @action(detail=True, methods=["get"]) + def postprocessing(self, request, pk, *args, **kwargs): + """Additional action to handle postprocessing.""" + self._pre_helper(pk, request) + self.output = get_config_postprocessing(self.config_details, request) + self.structured_format = "cli" + self.title_name = "Post Processing" + return self._post_render(request) + + @action(detail=True, methods=["get"]) + def sotagg(self, request, pk, *args, **kwargs): + """Additional action to handle sotagg.""" + self._pre_helper(pk, request) + self.structured_format = "json" + if request.GET.get("format") in ["json", "yaml"]: + self.structured_format = request.GET.get("format") + + settings = get_device_to_settings_map(queryset=Device.objects.filter(pk=self.device.pk)) + if self.device.id in settings: + sot_agg_query_setting = settings[self.device.id].sot_agg_query + if sot_agg_query_setting is not None: + _, self.output = graph_ql_query(request, self.device, sot_agg_query_setting.query) + else: + self.output = {"Error": "No saved `GraphQL Query` query was configured in the `Golden Config Setting`"} + else: + raise ObjectDoesNotExist(f"{self.device.name} does not map to a Golden Config Setting.") + + if self.structured_format == "yaml": + self.output = yaml.dump(json.loads(json.dumps(self.output)), default_flow_style=False) + else: + self.output = json.dumps(self.output, indent=4) + self.title_name = "Aggregate Data" + return self._post_render(request) + + @action(detail=True, methods=["get"]) + def compliance(self, request, pk, *args, **kwargs): + """Additional action to handle compliance.""" + self._pre_helper(pk, request) + + self.output = self.config_details.compliance_config + if self.config_details.backup_last_success_date: + backup_date = str(self.config_details.backup_last_success_date.strftime("%b %d %Y")) + else: + backup_date = make_aware(datetime.now()).strftime("%b %d %Y") + if self.config_details.intended_last_success_date: + intended_date = str(self.config_details.intended_last_success_date.strftime("%b %d %Y")) + else: + intended_date = make_aware(datetime.now()).strftime("%b %d %Y") + + diff_type = "File" + self.structured_format = "diff" + + if self.output == "": + # This is used if all config snippets are in compliance and no diff exist. + self.output = f"--- Backup {diff_type} - " + backup_date + f"\n+++ Intended {diff_type} - " + intended_date + else: + first_occurence = self.output.index("@@") + second_occurence = self.output.index("@@", first_occurence) + # This is logic to match diff2html's expected input. + self.output = ( + f"--- Backup {diff_type} - " + + backup_date + + f"\n+++ Intended {diff_type} - " + + intended_date + + "\n" + + self.output[first_occurence:second_occurence] + + "@@" + + self.output[second_occurence + 2 :] # noqa: E203 + ) + self.title_name = "Compliance Details" + return self._post_render(request) + + +# +# ConfigCompliance +# + + +class ConfigComplianceUIViewSet( # pylint: disable=abstract-method + views.ObjectDetailViewMixin, + views.ObjectDestroyViewMixin, + views.ObjectBulkDestroyViewMixin, + views.ObjectListViewMixin, +): + """Views for the ConfigCompliance model.""" + + filterset_class = filters.ConfigComplianceFilterSet + filterset_form_class = forms.ConfigComplianceFilterForm + queryset = models.ConfigCompliance.objects.all().order_by("device__name") + serializer_class = serializers.ConfigComplianceSerializer + table_class = tables.ConfigComplianceTable + table_delete_class = tables.ConfigComplianceDeleteTable + + custom_action_permission_map = None + action_buttons = ("export",) + + def __init__(self, *args, **kwargs): + """Used to set default variables on ConfigComplianceUIViewSet.""" + super().__init__(*args, **kwargs) + self.pk_list = None + self.report_context = None + self.store_table = None # Used to store the table for bulk delete. No longer required in Nautobot 2.3.11 + + def get_extra_context(self, request, instance=None, **kwargs): + """A ConfigCompliance helper function to warn if the Job is not enabled to run.""" + context = super().get_extra_context(request, instance) + if self.action == "overview": + context = {**context, **self.report_context} + # TODO Remove when dropping support for Nautobot < 2.3.11 + if self.action == "bulk_destroy": + context["table"] = self.store_table + + context["compliance"] = constant.ENABLE_COMPLIANCE + context["backup"] = constant.ENABLE_BACKUP + context["intended"] = constant.ENABLE_INTENDED + add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + return context + + def alter_queryset(self, request): + """Build actual runtime queryset as the build time queryset of table `pivoted`.""" + return pivot( + self.queryset, + ["device", "device__name"], + "rule__feature__slug", + "compliance_int", + aggregation=Max, + ) + + def perform_bulk_destroy(self, request, **kwargs): + """Overwrite perform_bulk_destroy to handle special use case in which the UI shows devices but want to delete ConfigCompliance objects.""" + model = self.queryset.model + # Are we deleting *all* objects in the queryset or just a selected subset? + if request.POST.get("_all"): + filter_params = self.get_filter_params(request) + if not filter_params: + compliance_objects = model.objects.only("pk").all().values_list("pk", flat=True) + elif self.filterset_class is None: + raise NotImplementedError("filterset_class must be defined to use _all") + else: + compliance_objects = self.filterset_class(filter_params, model.objects.only("pk")).qs + # When selecting *all* the resulting request args are ConfigCompliance object PKs + self.pk_list = [item[0] for item in self.queryset.filter(pk__in=compliance_objects).values_list("id")] + elif "_confirm" not in request.POST: + # When it is not being confirmed, the pk's are the device objects. + device_objects = request.POST.getlist("pk") + self.pk_list = [item[0] for item in self.queryset.filter(device__pk__in=device_objects).values_list("id")] + else: + self.pk_list = request.POST.getlist("pk") + + form_class = self.get_form_class(**kwargs) + data = {} + if "_confirm" in request.POST: + form = form_class(request.POST) + if form.is_valid(): + return self.form_valid(form) + return self.form_invalid(form) + + table = self.table_delete_class(self.queryset.filter(pk__in=self.pk_list), orderable=False) + + if not table.rows: + messages.warning( + request, + f"No {self.queryset.model._meta.verbose_name_plural} were selected for deletion.", + ) + return redirect(self.get_return_url(request)) + + # TODO Remove when dropping support for Nautobot < 2.3.11 + self.store_table = table + + if not request.POST.get("_all"): + data.update({"table": table, "total_objs_to_delete": len(table.rows)}) + else: + data.update({"table": None, "delete_all": True, "total_objs_to_delete": len(table.rows)}) + return Response(data) + + @action(detail=True, methods=["get"]) + def devicetab(self, request, pk, *args, **kwargs): + """Additional action to handle backup_config.""" + device = Device.objects.get(pk=pk) + context = {} + compliance_details = models.ConfigCompliance.objects.filter(device=device) + context["compliance_details"] = compliance_details + if request.GET.get("compliance") == "compliant": + context["compliance_details"] = compliance_details.filter(compliance=True) + elif request.GET.get("compliance") == "non-compliant": + context["compliance_details"] = compliance_details.filter(compliance=False) + + context["active_tab"] = request.GET.get("tab") + context["device"] = device + context["object"] = device + context["verbose_name"] = "Device" + return render(request, "nautobot_golden_config/configcompliance_devicetab.html", context) + + +class ConfigComplianceOverview(generic.ObjectListView): + """View for executive report on configuration compliance.""" + + action_buttons = ("export",) + filterset = filters.ConfigComplianceFilterSet + filterset_form = forms.ConfigComplianceFilterForm + table = tables.ConfigComplianceGlobalFeatureTable + template_name = "nautobot_golden_config/configcompliance_overview.html" + # kind = "Features" + + queryset = ( + models.ConfigCompliance.objects.values("rule__feature__slug") + .annotate( + count=Count("rule__feature__slug"), + compliant=Count("rule__feature__slug", filter=Q(compliance=True)), + non_compliant=Count("rule__feature__slug", filter=~Q(compliance=True)), + comp_percent=ExpressionWrapper(100 * F("compliant") / F("count"), output_field=FloatField()), + ) + .order_by("-comp_percent") + ) + extra_content = {} + + # Once https://github.com/nautobot/nautobot/issues/4529 is addressed, can turn this on. + # Permalink reference: https://github.com/nautobot/nautobot-app-golden-config/blob/017d5e1526fa9f642b9e02bfc7161f27d4948bef/nautobot_golden_config/views.py#L383 + # @action(detail=False, methods=["get"]) + # def overview(self, request, *args, **kwargs): + def setup(self, request, *args, **kwargs): + """Using request object to perform filtering based on query params.""" + super().setup(request, *args, **kwargs) + filter_params = self.get_filter_params(request) + main_qs = models.ConfigCompliance.objects + device_aggr, feature_aggr = get_global_aggr(main_qs, self.filterset, filter_params) + feature_qs = self.filterset(request.GET, self.queryset).qs + self.extra_content = { + "bar_chart": plot_barchart_visual(feature_qs), + "device_aggr": device_aggr, + "device_visual": plot_visual(device_aggr), + "feature_aggr": feature_aggr, + "feature_visual": plot_visual(feature_aggr), + "compliance": constant.ENABLE_COMPLIANCE, + } + def extra_context(self): + """Extra content method on.""" + # add global aggregations to extra context. + return self.extra_content -class ComplianceFeatureUIViewSet(NautobotUIViewSet): - """ViewSet for ComplianceFeature views.""" + +class ComplianceFeatureUIViewSet(views.NautobotUIViewSet): + """Views for the ComplianceFeature model.""" bulk_update_form_class = forms.ComplianceFeatureBulkEditForm filterset_class = filters.ComplianceFeatureFilterSet filterset_form_class = forms.ComplianceFeatureFilterForm form_class = forms.ComplianceFeatureForm - lookup_field = "pk" queryset = models.ComplianceFeature.objects.all() serializer_class = serializers.ComplianceFeatureSerializer table_class = tables.ComplianceFeatureTable + lookup_field = "pk" + + def get_extra_context(self, request, instance=None): + """A ComplianceFeature helper function to warn if the Job is not enabled to run.""" + add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + return {} + + +class ComplianceRuleUIViewSet(views.NautobotUIViewSet): + """Views for the ComplianceRule model.""" + + bulk_update_form_class = forms.ComplianceRuleBulkEditForm + filterset_class = filters.ComplianceRuleFilterSet + filterset_form_class = forms.ComplianceRuleFilterForm + form_class = forms.ComplianceRuleForm + queryset = models.ComplianceRule.objects.all() + serializer_class = serializers.ComplianceRuleSerializer + table_class = tables.ComplianceRuleTable + lookup_field = "pk" + + def get_extra_context(self, request, instance=None): + """A ComplianceRule helper function to warn if the Job is not enabled to run.""" + add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + return {} + + +class GoldenConfigSettingUIViewSet(views.NautobotUIViewSet): + """Views for the GoldenConfigSetting model.""" + + bulk_update_form_class = forms.GoldenConfigSettingBulkEditForm + filterset_class = filters.GoldenConfigSettingFilterSet + filterset_form_class = forms.GoldenConfigSettingFilterForm + form_class = forms.GoldenConfigSettingForm + queryset = models.GoldenConfigSetting.objects.all() + serializer_class = serializers.GoldenConfigSettingSerializer + table_class = tables.GoldenConfigSettingTable + lookup_field = "pk" + + def get_extra_context(self, request, instance=None): + """A GoldenConfig helper function to warn if the Job is not enabled to run.""" + jobs = [] + jobs.append(["BackupJob", constant.ENABLE_BACKUP]) + jobs.append(["IntendedJob", constant.ENABLE_INTENDED]) + jobs.append(["DeployConfigPlans", constant.ENABLE_DEPLOY]) + jobs.append(["ComplianceJob", constant.ENABLE_COMPLIANCE]) + jobs.append( + [ + "AllGoldenConfig", + [ + constant.ENABLE_BACKUP, + constant.ENABLE_COMPLIANCE, + constant.ENABLE_DEPLOY, + constant.ENABLE_INTENDED, + constant.ENABLE_SOTAGG, + ], + ] + ) + jobs.append( + [ + "AllDevicesGoldenConfig", + [ + constant.ENABLE_BACKUP, + constant.ENABLE_COMPLIANCE, + constant.ENABLE_DEPLOY, + constant.ENABLE_INTENDED, + constant.ENABLE_SOTAGG, + ], + ] + ) + add_message(jobs, request) + return {} + + +class ConfigRemoveUIViewSet(views.NautobotUIViewSet): + """Views for the ConfigRemove model.""" + + bulk_update_form_class = forms.ConfigRemoveBulkEditForm + filterset_class = filters.ConfigRemoveFilterSet + filterset_form_class = forms.ConfigRemoveFilterForm + form_class = forms.ConfigRemoveForm + queryset = models.ConfigRemove.objects.all() + serializer_class = serializers.ConfigRemoveSerializer + table_class = tables.ConfigRemoveTable + lookup_field = "pk" + + def get_extra_context(self, request, instance=None): + """A ConfigRemove helper function to warn if the Job is not enabled to run.""" + add_message([["BackupJob", constant.ENABLE_BACKUP]], request) + return {} + + +class ConfigReplaceUIViewSet(views.NautobotUIViewSet): + """Views for the ConfigReplace model.""" + + bulk_update_form_class = forms.ConfigReplaceBulkEditForm + filterset_class = filters.ConfigReplaceFilterSet + filterset_form_class = forms.ConfigReplaceFilterForm + form_class = forms.ConfigReplaceForm + queryset = models.ConfigReplace.objects.all() + serializer_class = serializers.ConfigReplaceSerializer + table_class = tables.ConfigReplaceTable + lookup_field = "pk" + + def get_extra_context(self, request, instance=None): + """A ConfigReplace helper function to warn if the Job is not enabled to run.""" + add_message([["BackupJob", constant.ENABLE_BACKUP]], request) + return {} + + +class RemediationSettingUIViewSet(views.NautobotUIViewSet): + """Views for the RemediationSetting model.""" + + # bulk_create_form_class = forms.RemediationSettingCSVForm + bulk_update_form_class = forms.RemediationSettingBulkEditForm + filterset_class = filters.RemediationSettingFilterSet + filterset_form_class = forms.RemediationSettingFilterForm + form_class = forms.RemediationSettingForm + queryset = models.RemediationSetting.objects.all() + serializer_class = serializers.RemediationSettingSerializer + table_class = tables.RemediationSettingTable + lookup_field = "pk" + + def get_extra_context(self, request, instance=None): + """A RemediationSetting helper function to warn if the Job is not enabled to run.""" + add_message([["ComplianceJob", constant.ENABLE_COMPLIANCE]], request) + return {} + + +class ConfigPlanUIViewSet(views.NautobotUIViewSet): + """Views for the ConfigPlan model.""" + + bulk_update_form_class = forms.ConfigPlanBulkEditForm + filterset_class = filters.ConfigPlanFilterSet + filterset_form_class = forms.ConfigPlanFilterForm + form_class = forms.ConfigPlanForm + queryset = models.ConfigPlan.objects.all() + serializer_class = serializers.ConfigPlanSerializer + table_class = tables.ConfigPlanTable + lookup_field = "pk" + action_buttons = ("add",) + update_form_class = forms.ConfigPlanUpdateForm + + def alter_queryset(self, request): + """Build actual runtime queryset to automatically remove `Completed` by default.""" + if "Completed" not in request.GET.getlist("status"): + return self.queryset.exclude(status__name="Completed") + return self.queryset + + def get_extra_context(self, request, instance=None): + """A ConfigPlan helper function to warn if the Job is not enabled to run.""" + jobs = [] + jobs.append(["GenerateConfigPlans", constant.ENABLE_PLAN]) + jobs.append(["DeployConfigPlans", constant.ENABLE_DEPLOY]) + jobs.append(["DeployConfigPlanJobButtonReceiver", constant.ENABLE_DEPLOY]) + add_message(jobs, request) + return {} + + +class ConfigPlanBulkDeploy(ObjectPermissionRequiredMixin, View): + """View to run the Config Plan Deploy Job.""" + + queryset = models.ConfigPlan.objects.all() + + def get_required_permission(self): + """Permissions required for the view.""" + return "extras.run_job" + + # Once https://github.com/nautobot/nautobot/issues/4529 is addressed, can turn this on. + # Permalink reference: https://github.com/nautobot/nautobot-app-golden-config/blob/017d5e1526fa9f642b9e02bfc7161f27d4948bef/nautobot_golden_config/views.py#L609-L612 + # @action(detail=False, methods=["post"]) + # def bulk_deploy(self, request): + def post(self, request): + """Enqueue the job and redirect to the job results page.""" + config_plan_pks = request.POST.getlist("pk") + if not config_plan_pks: + messages.warning(request, "No Config Plans selected for deployment.") + return redirect("plugins:nautobot_golden_config:configplan_list") + + job_data = {"config_plan": config_plan_pks} + job = Job.objects.get(name="Generate Config Plans") + + job_result = JobResult.enqueue_job( + job, + request.user, + data=job_data, + **job.job_class.serialize_data(request), + ) + return redirect(job_result.get_absolute_url()) + + +class GenerateIntendedConfigView(PermissionRequiredMixin, TemplateView): + """View to generate the intended configuration.""" + + template_name = "nautobot_golden_config/generate_intended_config.html" + permission_required = ["dcim.view_device", "extras.view_gitrepository"] + + def get_context_data(self, **kwargs): + """Get the context data for the view.""" + context = super().get_context_data(**kwargs) + context["form"] = forms.GenerateIntendedConfigForm() + return context diff --git a/poetry.lock b/poetry.lock index d7321b2f..f3bc1a82 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "amqp" @@ -25,6 +25,28 @@ files = [ {file = "aniso8601-7.0.0.tar.gz", hash = "sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e"}, ] +[[package]] +name = "anyio" +version = "4.5.2" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, + {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +trio = ["trio (>=0.26.1)"] + [[package]] name = "appnope" version = "0.1.4" @@ -206,6 +228,44 @@ tzdata = {version = "*", optional = true, markers = "extra == \"tzdata\""} [package.extras] tzdata = ["tzdata"] +[[package]] +name = "bcrypt" +version = "4.2.1" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.2.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:1340411a0894b7d3ef562fb233e4b6ed58add185228650942bdc885362f32c17"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ee315739bc8387aa36ff127afc99120ee452924e0df517a8f3e4c0187a0f5f"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dbd0747208912b1e4ce730c6725cb56c07ac734b3629b60d4398f082ea718ad"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:aaa2e285be097050dba798d537b6efd9b698aa88eef52ec98d23dcd6d7cf6fea"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76d3e352b32f4eeb34703370e370997065d28a561e4a18afe4fef07249cb4396"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b7703ede632dc945ed1172d6f24e9f30f27b1b1a067f32f68bf169c5f08d0425"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89df2aea2c43be1e1fa066df5f86c8ce822ab70a30e4c210968669565c0f4685"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e56e3fe8308a88b77e0afd20bec516f74aecf391cdd6e374f15cbed32783d6"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdf3d7530c790432046c40cda41dfee8c83e29482e6a604f8930b9930e94139"}, + {file = "bcrypt-4.2.1-cp37-abi3-win32.whl", hash = "sha256:adadd36274510a01f33e6dc08f5824b97c9580583bd4487c564fc4617b328005"}, + {file = "bcrypt-4.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:8c458cd103e6c5d1d85cf600e546a639f234964d0228909d8f8dbeebff82d526"}, + {file = "bcrypt-4.2.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:8ad2f4528cbf0febe80e5a3a57d7a74e6635e41af1ea5675282a33d769fba413"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:909faa1027900f2252a9ca5dfebd25fc0ef1417943824783d1c8418dd7d6df4a"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cde78d385d5e93ece5479a0a87f73cd6fa26b171c786a884f955e165032b262c"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:533e7f3bcf2f07caee7ad98124fab7499cb3333ba2274f7a36cf1daee7409d99"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:687cf30e6681eeda39548a93ce9bfbb300e48b4d445a43db4298d2474d2a1e54"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:041fa0155c9004eb98a232d54da05c0b41d4b8e66b6fc3cb71b4b3f6144ba837"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f85b1ffa09240c89aa2e1ae9f3b1c687104f7b2b9d2098da4e923f1b7082d331"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c6f5fa3775966cca251848d4d5393ab016b3afed251163c1436fefdec3b02c84"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:807261df60a8b1ccd13e6599c779014a362ae4e795f5c59747f60208daddd96d"}, + {file = "bcrypt-4.2.1-cp39-abi3-win32.whl", hash = "sha256:b588af02b89d9fad33e5f98f7838bf590d6d692df7153647724a7f20c186f6bf"}, + {file = "bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c"}, + {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76132c176a6d9953cdc83c296aeaed65e1a708485fd55abf163e0d9f8f16ce0e"}, + {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e158009a54c4c8bc91d5e0da80920d048f918c61a581f0a63e4e93bb556d362f"}, + {file = "bcrypt-4.2.1.tar.gz", hash = "sha256:6765386e3ab87f569b276988742039baab087b2cdb01e809d74e74503c2faafe"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "billiard" version = "4.2.1" @@ -555,6 +615,80 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "contourpy" +version = "1.1.1" +description = "Python library for calculating contours of 2D quadrilateral grids" +optional = false +python-versions = ">=3.8" +files = [ + {file = "contourpy-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:46e24f5412c948d81736509377e255f6040e94216bf1a9b5ea1eaa9d29f6ec1b"}, + {file = "contourpy-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e48694d6a9c5a26ee85b10130c77a011a4fedf50a7279fa0bdaf44bafb4299d"}, + {file = "contourpy-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a66045af6cf00e19d02191ab578a50cb93b2028c3eefed999793698e9ea768ae"}, + {file = "contourpy-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ebf42695f75ee1a952f98ce9775c873e4971732a87334b099dde90b6af6a916"}, + {file = "contourpy-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6aec19457617ef468ff091669cca01fa7ea557b12b59a7908b9474bb9674cf0"}, + {file = "contourpy-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:462c59914dc6d81e0b11f37e560b8a7c2dbab6aca4f38be31519d442d6cde1a1"}, + {file = "contourpy-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6d0a8efc258659edc5299f9ef32d8d81de8b53b45d67bf4bfa3067f31366764d"}, + {file = "contourpy-1.1.1-cp310-cp310-win32.whl", hash = "sha256:d6ab42f223e58b7dac1bb0af32194a7b9311065583cc75ff59dcf301afd8a431"}, + {file = "contourpy-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:549174b0713d49871c6dee90a4b499d3f12f5e5f69641cd23c50a4542e2ca1eb"}, + {file = "contourpy-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:407d864db716a067cc696d61fa1ef6637fedf03606e8417fe2aeed20a061e6b2"}, + {file = "contourpy-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe80c017973e6a4c367e037cb31601044dd55e6bfacd57370674867d15a899b"}, + {file = "contourpy-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e30aaf2b8a2bac57eb7e1650df1b3a4130e8d0c66fc2f861039d507a11760e1b"}, + {file = "contourpy-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de23ca4f381c3770dee6d10ead6fff524d540c0f662e763ad1530bde5112532"}, + {file = "contourpy-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:566f0e41df06dfef2431defcfaa155f0acfa1ca4acbf8fd80895b1e7e2ada40e"}, + {file = "contourpy-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b04c2f0adaf255bf756cf08ebef1be132d3c7a06fe6f9877d55640c5e60c72c5"}, + {file = "contourpy-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d0c188ae66b772d9d61d43c6030500344c13e3f73a00d1dc241da896f379bb62"}, + {file = "contourpy-1.1.1-cp311-cp311-win32.whl", hash = "sha256:0683e1ae20dc038075d92e0e0148f09ffcefab120e57f6b4c9c0f477ec171f33"}, + {file = "contourpy-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:8636cd2fc5da0fb102a2504fa2c4bea3cbc149533b345d72cdf0e7a924decc45"}, + {file = "contourpy-1.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:560f1d68a33e89c62da5da4077ba98137a5e4d3a271b29f2f195d0fba2adcb6a"}, + {file = "contourpy-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24216552104ae8f3b34120ef84825400b16eb6133af2e27a190fdc13529f023e"}, + {file = "contourpy-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56de98a2fb23025882a18b60c7f0ea2d2d70bbbcfcf878f9067234b1c4818442"}, + {file = "contourpy-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07d6f11dfaf80a84c97f1a5ba50d129d9303c5b4206f776e94037332e298dda8"}, + {file = "contourpy-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1eaac5257a8f8a047248d60e8f9315c6cff58f7803971170d952555ef6344a7"}, + {file = "contourpy-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19557fa407e70f20bfaba7d55b4d97b14f9480856c4fb65812e8a05fe1c6f9bf"}, + {file = "contourpy-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:081f3c0880712e40effc5f4c3b08feca6d064cb8cfbb372ca548105b86fd6c3d"}, + {file = "contourpy-1.1.1-cp312-cp312-win32.whl", hash = "sha256:059c3d2a94b930f4dafe8105bcdc1b21de99b30b51b5bce74c753686de858cb6"}, + {file = "contourpy-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f44d78b61740e4e8c71db1cf1fd56d9050a4747681c59ec1094750a658ceb970"}, + {file = "contourpy-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:70e5a10f8093d228bb2b552beeb318b8928b8a94763ef03b858ef3612b29395d"}, + {file = "contourpy-1.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8394e652925a18ef0091115e3cc191fef350ab6dc3cc417f06da66bf98071ae9"}, + {file = "contourpy-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5bd5680f844c3ff0008523a71949a3ff5e4953eb7701b28760805bc9bcff217"}, + {file = "contourpy-1.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66544f853bfa85c0d07a68f6c648b2ec81dafd30f272565c37ab47a33b220684"}, + {file = "contourpy-1.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0c02b75acfea5cab07585d25069207e478d12309557f90a61b5a3b4f77f46ce"}, + {file = "contourpy-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41339b24471c58dc1499e56783fedc1afa4bb018bcd035cfb0ee2ad2a7501ef8"}, + {file = "contourpy-1.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f29fb0b3f1217dfe9362ec55440d0743fe868497359f2cf93293f4b2701b8251"}, + {file = "contourpy-1.1.1-cp38-cp38-win32.whl", hash = "sha256:f9dc7f933975367251c1b34da882c4f0e0b2e24bb35dc906d2f598a40b72bfc7"}, + {file = "contourpy-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:498e53573e8b94b1caeb9e62d7c2d053c263ebb6aa259c81050766beb50ff8d9"}, + {file = "contourpy-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ba42e3810999a0ddd0439e6e5dbf6d034055cdc72b7c5c839f37a7c274cb4eba"}, + {file = "contourpy-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c06e4c6e234fcc65435223c7b2a90f286b7f1b2733058bdf1345d218cc59e34"}, + {file = "contourpy-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca6fab080484e419528e98624fb5c4282148b847e3602dc8dbe0cb0669469887"}, + {file = "contourpy-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93df44ab351119d14cd1e6b52a5063d3336f0754b72736cc63db59307dabb718"}, + {file = "contourpy-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eafbef886566dc1047d7b3d4b14db0d5b7deb99638d8e1be4e23a7c7ac59ff0f"}, + {file = "contourpy-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe0fab26d598e1ec07d72cf03eaeeba8e42b4ecf6b9ccb5a356fde60ff08b85"}, + {file = "contourpy-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f08e469821a5e4751c97fcd34bcb586bc243c39c2e39321822060ba902eac49e"}, + {file = "contourpy-1.1.1-cp39-cp39-win32.whl", hash = "sha256:bfc8a5e9238232a45ebc5cb3bfee71f1167064c8d382cadd6076f0d51cff1da0"}, + {file = "contourpy-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:c84fdf3da00c2827d634de4fcf17e3e067490c4aea82833625c4c8e6cdea0887"}, + {file = "contourpy-1.1.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:229a25f68046c5cf8067d6d6351c8b99e40da11b04d8416bf8d2b1d75922521e"}, + {file = "contourpy-1.1.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a10dab5ea1bd4401c9483450b5b0ba5416be799bbd50fc7a6cc5e2a15e03e8a3"}, + {file = "contourpy-1.1.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4f9147051cb8fdb29a51dc2482d792b3b23e50f8f57e3720ca2e3d438b7adf23"}, + {file = "contourpy-1.1.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a75cc163a5f4531a256f2c523bd80db509a49fc23721b36dd1ef2f60ff41c3cb"}, + {file = "contourpy-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b53d5769aa1f2d4ea407c65f2d1d08002952fac1d9e9d307aa2e1023554a163"}, + {file = "contourpy-1.1.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:11b836b7dbfb74e049c302bbf74b4b8f6cb9d0b6ca1bf86cfa8ba144aedadd9c"}, + {file = "contourpy-1.1.1.tar.gz", hash = "sha256:96ba37c2e24b7212a77da85004c38e7c4d155d3e72a45eeaf22c1f03f607e8ab"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.16,<2.0", markers = "python_version <= \"3.11\""}, + {version = ">=1.26.0rc1,<2.0", markers = "python_version >= \"3.12\""}, +] + +[package.extras] +bokeh = ["bokeh", "selenium"] +docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] +mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.4.1)", "types-Pillow"] +test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] +test-no-images = ["pytest", "pytest-cov", "wurlitzer"] + [[package]] name = "coverage" version = "7.6.1" @@ -702,6 +836,21 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cycler" +version = "0.12.1" +description = "Composable style cycles" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, + {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, +] + +[package.extras] +docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] +tests = ["pytest", "pytest-cov", "pytest-xdist"] + [[package]] name = "decorator" version = "5.1.1" @@ -713,6 +862,24 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "deepdiff" +version = "7.0.1" +description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." +optional = false +python-versions = ">=3.8" +files = [ + {file = "deepdiff-7.0.1-py3-none-any.whl", hash = "sha256:447760081918216aa4fd4ca78a4b6a848b81307b2ea94c810255334b759e1dc3"}, + {file = "deepdiff-7.0.1.tar.gz", hash = "sha256:260c16f052d4badbf60351b4f77e8390bee03a0b516246f6839bc813fb429ddf"}, +] + +[package.dependencies] +ordered-set = ">=4.1.0,<4.2.0" + +[package.extras] +cli = ["click (==8.1.7)", "pyyaml (==6.0.1)"] +optimize = ["orjson"] + [[package]] name = "defusedxml" version = "0.7.1" @@ -853,17 +1020,17 @@ Django = "*" [[package]] name = "django-debug-toolbar" -version = "4.4.6" +version = "4.3.0" description = "A configurable set of panels that display various debug information about the current request/response." optional = false python-versions = ">=3.8" files = [ - {file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"}, - {file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"}, + {file = "django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6"}, + {file = "django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4"}, ] [package.dependencies] -django = ">=4.2.9" +django = ">=3.2.4" sqlparse = ">=0.2" [[package]] @@ -958,6 +1125,20 @@ Django = ">=3.2" [package.extras] tests = ["tox"] +[[package]] +name = "django-pivot" +version = "1.9.0" +description = "Create pivot tables and histograms from ORM querysets" +optional = false +python-versions = "*" +files = [ + {file = "django-pivot-1.9.0.tar.gz", hash = "sha256:5e985d32d9ff2a6b89419dd0292c0fa2822d494ee479b5fd16cdb542abf66a88"}, + {file = "django_pivot-1.9.0-py3-none-any.whl", hash = "sha256:1c60e18e7d5f7e42856faee0961748082ddd05b01ae7c8a4baed64d2bbacd051"}, +] + +[package.dependencies] +django = ">=2.2.0" + [[package]] name = "django-prometheus" version = "2.3.1" @@ -1212,6 +1393,20 @@ typing-extensions = ">=4.7.0" [package.extras] dev = ["coverage", "pytest (>=7.4.4)"] +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "2.1.0" @@ -1226,6 +1421,90 @@ files = [ [package.extras] tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] +[[package]] +name = "fonttools" +version = "4.55.3" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fonttools-4.55.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1dcc07934a2165ccdc3a5a608db56fb3c24b609658a5b340aee4ecf3ba679dc0"}, + {file = "fonttools-4.55.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7d66c15ba875432a2d2fb419523f5d3d347f91f48f57b8b08a2dfc3c39b8a3f"}, + {file = "fonttools-4.55.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e4ae3592e62eba83cd2c4ccd9462dcfa603ff78e09110680a5444c6925d841"}, + {file = "fonttools-4.55.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62d65a3022c35e404d19ca14f291c89cc5890032ff04f6c17af0bd1927299674"}, + {file = "fonttools-4.55.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d342e88764fb201286d185093781bf6628bbe380a913c24adf772d901baa8276"}, + {file = "fonttools-4.55.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd68c87a2bfe37c5b33bcda0fba39b65a353876d3b9006fde3adae31f97b3ef5"}, + {file = "fonttools-4.55.3-cp310-cp310-win32.whl", hash = "sha256:1bc7ad24ff98846282eef1cbeac05d013c2154f977a79886bb943015d2b1b261"}, + {file = "fonttools-4.55.3-cp310-cp310-win_amd64.whl", hash = "sha256:b54baf65c52952db65df39fcd4820668d0ef4766c0ccdf32879b77f7c804d5c5"}, + {file = "fonttools-4.55.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c4491699bad88efe95772543cd49870cf756b019ad56294f6498982408ab03e"}, + {file = "fonttools-4.55.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5323a22eabddf4b24f66d26894f1229261021dacd9d29e89f7872dd8c63f0b8b"}, + {file = "fonttools-4.55.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5480673f599ad410695ca2ddef2dfefe9df779a9a5cda89503881e503c9c7d90"}, + {file = "fonttools-4.55.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da9da6d65cd7aa6b0f806556f4985bcbf603bf0c5c590e61b43aa3e5a0f822d0"}, + {file = "fonttools-4.55.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e894b5bd60d9f473bed7a8f506515549cc194de08064d829464088d23097331b"}, + {file = "fonttools-4.55.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aee3b57643827e237ff6ec6d28d9ff9766bd8b21e08cd13bff479e13d4b14765"}, + {file = "fonttools-4.55.3-cp311-cp311-win32.whl", hash = "sha256:eb6ca911c4c17eb51853143624d8dc87cdcdf12a711fc38bf5bd21521e79715f"}, + {file = "fonttools-4.55.3-cp311-cp311-win_amd64.whl", hash = "sha256:6314bf82c54c53c71805318fcf6786d986461622dd926d92a465199ff54b1b72"}, + {file = "fonttools-4.55.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f9e736f60f4911061235603a6119e72053073a12c6d7904011df2d8fad2c0e35"}, + {file = "fonttools-4.55.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a8aa2c5e5b8b3bcb2e4538d929f6589a5c6bdb84fd16e2ed92649fb5454f11c"}, + {file = "fonttools-4.55.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07f8288aacf0a38d174445fc78377a97fb0b83cfe352a90c9d9c1400571963c7"}, + {file = "fonttools-4.55.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8d5e8916c0970fbc0f6f1bece0063363bb5857a7f170121a4493e31c3db3314"}, + {file = "fonttools-4.55.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae3b6600565b2d80b7c05acb8e24d2b26ac407b27a3f2e078229721ba5698427"}, + {file = "fonttools-4.55.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:54153c49913f45065c8d9e6d0c101396725c5621c8aee744719300f79771d75a"}, + {file = "fonttools-4.55.3-cp312-cp312-win32.whl", hash = "sha256:827e95fdbbd3e51f8b459af5ea10ecb4e30af50221ca103bea68218e9615de07"}, + {file = "fonttools-4.55.3-cp312-cp312-win_amd64.whl", hash = "sha256:e6e8766eeeb2de759e862004aa11a9ea3d6f6d5ec710551a88b476192b64fd54"}, + {file = "fonttools-4.55.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a430178ad3e650e695167cb53242dae3477b35c95bef6525b074d87493c4bf29"}, + {file = "fonttools-4.55.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:529cef2ce91dc44f8e407cc567fae6e49a1786f2fefefa73a294704c415322a4"}, + {file = "fonttools-4.55.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e75f12c82127486fac2d8bfbf5bf058202f54bf4f158d367e41647b972342ca"}, + {file = "fonttools-4.55.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:859c358ebf41db18fb72342d3080bce67c02b39e86b9fbcf1610cca14984841b"}, + {file = "fonttools-4.55.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:546565028e244a701f73df6d8dd6be489d01617863ec0c6a42fa25bf45d43048"}, + {file = "fonttools-4.55.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aca318b77f23523309eec4475d1fbbb00a6b133eb766a8bdc401faba91261abe"}, + {file = "fonttools-4.55.3-cp313-cp313-win32.whl", hash = "sha256:8c5ec45428edaa7022f1c949a632a6f298edc7b481312fc7dc258921e9399628"}, + {file = "fonttools-4.55.3-cp313-cp313-win_amd64.whl", hash = "sha256:11e5de1ee0d95af4ae23c1a138b184b7f06e0b6abacabf1d0db41c90b03d834b"}, + {file = "fonttools-4.55.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:caf8230f3e10f8f5d7593eb6d252a37caf58c480b19a17e250a63dad63834cf3"}, + {file = "fonttools-4.55.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b586ab5b15b6097f2fb71cafa3c98edfd0dba1ad8027229e7b1e204a58b0e09d"}, + {file = "fonttools-4.55.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8c2794ded89399cc2169c4d0bf7941247b8d5932b2659e09834adfbb01589aa"}, + {file = "fonttools-4.55.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf4fe7c124aa3f4e4c1940880156e13f2f4d98170d35c749e6b4f119a872551e"}, + {file = "fonttools-4.55.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:86721fbc389ef5cc1e2f477019e5069e8e4421e8d9576e9c26f840dbb04678de"}, + {file = "fonttools-4.55.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:89bdc5d88bdeec1b15af790810e267e8332d92561dce4f0748c2b95c9bdf3926"}, + {file = "fonttools-4.55.3-cp38-cp38-win32.whl", hash = "sha256:bc5dbb4685e51235ef487e4bd501ddfc49be5aede5e40f4cefcccabc6e60fb4b"}, + {file = "fonttools-4.55.3-cp38-cp38-win_amd64.whl", hash = "sha256:cd70de1a52a8ee2d1877b6293af8a2484ac82514f10b1c67c1c5762d38073e56"}, + {file = "fonttools-4.55.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bdcc9f04b36c6c20978d3f060e5323a43f6222accc4e7fcbef3f428e216d96af"}, + {file = "fonttools-4.55.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c3ca99e0d460eff46e033cd3992a969658c3169ffcd533e0a39c63a38beb6831"}, + {file = "fonttools-4.55.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22f38464daa6cdb7b6aebd14ab06609328fe1e9705bb0fcc7d1e69de7109ee02"}, + {file = "fonttools-4.55.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed63959d00b61959b035c7d47f9313c2c1ece090ff63afea702fe86de00dbed4"}, + {file = "fonttools-4.55.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5e8d657cd7326eeaba27de2740e847c6b39dde2f8d7cd7cc56f6aad404ddf0bd"}, + {file = "fonttools-4.55.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:fb594b5a99943042c702c550d5494bdd7577f6ef19b0bc73877c948a63184a32"}, + {file = "fonttools-4.55.3-cp39-cp39-win32.whl", hash = "sha256:dc5294a3d5c84226e3dbba1b6f61d7ad813a8c0238fceea4e09aa04848c3d851"}, + {file = "fonttools-4.55.3-cp39-cp39-win_amd64.whl", hash = "sha256:aedbeb1db64496d098e6be92b2e63b5fac4e53b1b92032dfc6988e1ea9134a4d"}, + {file = "fonttools-4.55.3-py3-none-any.whl", hash = "sha256:f412604ccbeee81b091b420272841e5ec5ef68967a9790e80bffd0e30b8e2977"}, + {file = "fonttools-4.55.3.tar.gz", hash = "sha256:3983313c2a04d6cc1fe9251f8fc647754cf49a61dac6cb1e7249ae67afaafc45"}, +] + +[package.extras] +all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres", "pycairo", "scipy"] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +ufo = ["fs (>=2.2.0,<3)"] +unicode = ["unicodedata2 (>=15.1.0)"] +woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] + +[[package]] +name = "future" +version = "1.0.0" +description = "Clean single-source support for Python 3 and 2" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216"}, + {file = "future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"}, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -1393,6 +1672,75 @@ files = [ astunparse = {version = ">=1.6", markers = "python_version < \"3.9\""} colorama = ">=0.4" +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "hier-config" +version = "2.2.3" +description = "A network configuration comparison tool, used to build remediation configurations." +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "hier_config-2.2.3-py3-none-any.whl", hash = "sha256:9adb860278afcf3813a49b75886649c9a21f7cc0c89f9d720f47ce8edcf021ca"}, + {file = "hier_config-2.2.3.tar.gz", hash = "sha256:6b0fb526c229b0f930f15a67be742d36230bf75a3041bf1d9d9487bbf9b01277"}, +] + +[package.dependencies] +PyYAML = ">=5.4" + +[[package]] +name = "httpcore" +version = "0.17.3" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, + {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, +] + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = "==1.*" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "httpx" +version = "0.24.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, + {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, +] + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.18.0" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "idna" version = "3.10" @@ -1409,26 +1757,22 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "4.13.0" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, + {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, + {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, ] [package.dependencies] -zipp = ">=3.20" +zipp = ">=0.5" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "importlib-resources" @@ -1619,6 +1963,152 @@ files = [ importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} referencing = ">=0.31.0" +[[package]] +name = "junos-eznc" +version = "2.7.1" +description = "Junos 'EZ' automation for non-programmers" +optional = false +python-versions = ">=3.8" +files = [ + {file = "junos-eznc-2.7.1.tar.gz", hash = "sha256:371f0298bf03e0cb4c017c43f6f4122263584eda0d690d0112e93f13daae41ac"}, + {file = "junos_eznc-2.7.1-py3-none-any.whl", hash = "sha256:8a7918faa8f0570341cac64c1210c1cd3e3542162d1e7449c3364f8d805716b2"}, +] + +[package.dependencies] +jinja2 = ">=2.7.1" +lxml = ">=3.2.4" +ncclient = ">=0.6.15" +pyparsing = "*" +pyserial = "*" +PyYAML = ">=5.1" +scp = ">=0.7.0" +six = "*" +transitions = "*" +yamlordereddictloader = "*" + +[[package]] +name = "kiwisolver" +version = "1.4.7" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.8" +files = [ + {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6"}, + {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17"}, + {file = "kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05"}, + {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895"}, + {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3"}, + {file = "kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc"}, + {file = "kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c"}, + {file = "kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a"}, + {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54"}, + {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95"}, + {file = "kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052"}, + {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3"}, + {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523"}, + {file = "kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d"}, + {file = "kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b"}, + {file = "kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376"}, + {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2"}, + {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a"}, + {file = "kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258"}, + {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383"}, + {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520"}, + {file = "kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b"}, + {file = "kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb"}, + {file = "kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a"}, + {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e"}, + {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6"}, + {file = "kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34"}, + {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a"}, + {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee"}, + {file = "kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07"}, + {file = "kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76"}, + {file = "kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650"}, + {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5d5abf8f8ec1f4e22882273c423e16cae834c36856cac348cfbfa68e01c40f3a"}, + {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeb3531b196ef6f11776c21674dba836aeea9d5bd1cf630f869e3d90b16cfade"}, + {file = "kiwisolver-1.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7d755065e4e866a8086c9bdada157133ff466476a2ad7861828e17b6026e22c"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08471d4d86cbaec61f86b217dd938a83d85e03785f51121e791a6e6689a3be95"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bbfcb7165ce3d54a3dfbe731e470f65739c4c1f85bb1018ee912bae139e263b"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d34eb8494bea691a1a450141ebb5385e4b69d38bb8403b5146ad279f4b30fa3"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9242795d174daa40105c1d86aba618e8eab7bf96ba8c3ee614da8302a9f95503"}, + {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a0f64a48bb81af7450e641e3fe0b0394d7381e342805479178b3d335d60ca7cf"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8e045731a5416357638d1700927529e2b8ab304811671f665b225f8bf8d8f933"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4322872d5772cae7369f8351da1edf255a604ea7087fe295411397d0cfd9655e"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e1631290ee9271dffe3062d2634c3ecac02c83890ada077d225e081aca8aab89"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:edcfc407e4eb17e037bca59be0e85a2031a2ac87e4fed26d3e9df88b4165f92d"}, + {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4d05d81ecb47d11e7f8932bd8b61b720bf0b41199358f3f5e36d38e28f0532c5"}, + {file = "kiwisolver-1.4.7-cp38-cp38-win32.whl", hash = "sha256:b38ac83d5f04b15e515fd86f312479d950d05ce2368d5413d46c088dda7de90a"}, + {file = "kiwisolver-1.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:d83db7cde68459fc803052a55ace60bea2bae361fc3b7a6d5da07e11954e4b09"}, + {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd"}, + {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583"}, + {file = "kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2"}, + {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb"}, + {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327"}, + {file = "kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644"}, + {file = "kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4"}, + {file = "kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4"}, + {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bfa1acfa0c54932d5607e19a2c24646fb4c1ae2694437789129cf099789a3b00"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:eee3ea935c3d227d49b4eb85660ff631556841f6e567f0f7bda972df6c2c9935"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f3160309af4396e0ed04db259c3ccbfdc3621b5559b5453075e5de555e1f3a1b"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a17f6a29cf8935e587cc8a4dbfc8368c55edc645283db0ce9801016f83526c2d"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10849fb2c1ecbfae45a693c070e0320a91b35dd4bcf58172c023b994283a124d"}, + {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ac542bf38a8a4be2dc6b15248d36315ccc65f0743f7b1a76688ffb6b5129a5c2"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225"}, + {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0"}, + {file = "kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60"}, +] + [[package]] name = "kombu" version = "5.4.2" @@ -1700,6 +2190,160 @@ files = [ {file = "lazy_object_proxy-1.10.0-pp310.pp311.pp312.pp38.pp39-none-any.whl", hash = "sha256:80fa48bd89c8f2f456fc0765c11c23bf5af827febacd2f523ca5bc1893fcc09d"}, ] +[[package]] +name = "lxml" +version = "5.3.0" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.6" +files = [ + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"}, + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"}, + {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"}, + {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"}, + {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"}, + {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"}, + {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"}, + {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"}, + {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"}, + {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"}, + {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"}, + {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"}, + {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"}, + {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"}, + {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"}, + {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"}, + {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"}, + {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"}, + {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"}, + {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"}, + {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"}, + {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"}, + {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml-html-clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=3.0.11)"] + [[package]] name = "markdown" version = "3.6" @@ -1801,6 +2445,74 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "matplotlib" +version = "3.7.5" +description = "Python plotting package" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib-3.7.5-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:4a87b69cb1cb20943010f63feb0b2901c17a3b435f75349fd9865713bfa63925"}, + {file = "matplotlib-3.7.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d3ce45010fefb028359accebb852ca0c21bd77ec0f281952831d235228f15810"}, + {file = "matplotlib-3.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbea1e762b28400393d71be1a02144aa16692a3c4c676ba0178ce83fc2928fdd"}, + {file = "matplotlib-3.7.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec0e1adc0ad70ba8227e957551e25a9d2995e319c29f94a97575bb90fa1d4469"}, + {file = "matplotlib-3.7.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6738c89a635ced486c8a20e20111d33f6398a9cbebce1ced59c211e12cd61455"}, + {file = "matplotlib-3.7.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1210b7919b4ed94b5573870f316bca26de3e3b07ffdb563e79327dc0e6bba515"}, + {file = "matplotlib-3.7.5-cp310-cp310-win32.whl", hash = "sha256:068ebcc59c072781d9dcdb82f0d3f1458271c2de7ca9c78f5bd672141091e9e1"}, + {file = "matplotlib-3.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:f098ffbaab9df1e3ef04e5a5586a1e6b1791380698e84938d8640961c79b1fc0"}, + {file = "matplotlib-3.7.5-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:f65342c147572673f02a4abec2d5a23ad9c3898167df9b47c149f32ce61ca078"}, + {file = "matplotlib-3.7.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4ddf7fc0e0dc553891a117aa083039088d8a07686d4c93fb8a810adca68810af"}, + {file = "matplotlib-3.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ccb830fc29442360d91be48527809f23a5dcaee8da5f4d9b2d5b867c1b087b8"}, + {file = "matplotlib-3.7.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efc6bb28178e844d1f408dd4d6341ee8a2e906fc9e0fa3dae497da4e0cab775d"}, + {file = "matplotlib-3.7.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b15c4c2d374f249f324f46e883340d494c01768dd5287f8bc00b65b625ab56c"}, + {file = "matplotlib-3.7.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d028555421912307845e59e3de328260b26d055c5dac9b182cc9783854e98fb"}, + {file = "matplotlib-3.7.5-cp311-cp311-win32.whl", hash = "sha256:fe184b4625b4052fa88ef350b815559dd90cc6cc8e97b62f966e1ca84074aafa"}, + {file = "matplotlib-3.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:084f1f0f2f1010868c6f1f50b4e1c6f2fb201c58475494f1e5b66fed66093647"}, + {file = "matplotlib-3.7.5-cp312-cp312-macosx_10_12_universal2.whl", hash = "sha256:34bceb9d8ddb142055ff27cd7135f539f2f01be2ce0bafbace4117abe58f8fe4"}, + {file = "matplotlib-3.7.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c5a2134162273eb8cdfd320ae907bf84d171de948e62180fa372a3ca7cf0f433"}, + {file = "matplotlib-3.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:039ad54683a814002ff37bf7981aa1faa40b91f4ff84149beb53d1eb64617980"}, + {file = "matplotlib-3.7.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d742ccd1b09e863b4ca58291728db645b51dab343eebb08d5d4b31b308296ce"}, + {file = "matplotlib-3.7.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:743b1c488ca6a2bc7f56079d282e44d236bf375968bfd1b7ba701fd4d0fa32d6"}, + {file = "matplotlib-3.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:fbf730fca3e1f23713bc1fae0a57db386e39dc81ea57dc305c67f628c1d7a342"}, + {file = "matplotlib-3.7.5-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:cfff9b838531698ee40e40ea1a8a9dc2c01edb400b27d38de6ba44c1f9a8e3d2"}, + {file = "matplotlib-3.7.5-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:1dbcca4508bca7847fe2d64a05b237a3dcaec1f959aedb756d5b1c67b770c5ee"}, + {file = "matplotlib-3.7.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4cdf4ef46c2a1609a50411b66940b31778db1e4b73d4ecc2eaa40bd588979b13"}, + {file = "matplotlib-3.7.5-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:167200ccfefd1674b60e957186dfd9baf58b324562ad1a28e5d0a6b3bea77905"}, + {file = "matplotlib-3.7.5-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:53e64522934df6e1818b25fd48cf3b645b11740d78e6ef765fbb5fa5ce080d02"}, + {file = "matplotlib-3.7.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3e3bc79b2d7d615067bd010caff9243ead1fc95cf735c16e4b2583173f717eb"}, + {file = "matplotlib-3.7.5-cp38-cp38-win32.whl", hash = "sha256:6b641b48c6819726ed47c55835cdd330e53747d4efff574109fd79b2d8a13748"}, + {file = "matplotlib-3.7.5-cp38-cp38-win_amd64.whl", hash = "sha256:f0b60993ed3488b4532ec6b697059897891927cbfc2b8d458a891b60ec03d9d7"}, + {file = "matplotlib-3.7.5-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:090964d0afaff9c90e4d8de7836757e72ecfb252fb02884016d809239f715651"}, + {file = "matplotlib-3.7.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:9fc6fcfbc55cd719bc0bfa60bde248eb68cf43876d4c22864603bdd23962ba25"}, + {file = "matplotlib-3.7.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7cc3078b019bb863752b8b60e8b269423000f1603cb2299608231996bd9d54"}, + {file = "matplotlib-3.7.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e4e9a868e8163abaaa8259842d85f949a919e1ead17644fb77a60427c90473c"}, + {file = "matplotlib-3.7.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa7ebc995a7d747dacf0a717d0eb3aa0f0c6a0e9ea88b0194d3a3cd241a1500f"}, + {file = "matplotlib-3.7.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3785bfd83b05fc0e0c2ae4c4a90034fe693ef96c679634756c50fe6efcc09856"}, + {file = "matplotlib-3.7.5-cp39-cp39-win32.whl", hash = "sha256:29b058738c104d0ca8806395f1c9089dfe4d4f0f78ea765c6c704469f3fffc81"}, + {file = "matplotlib-3.7.5-cp39-cp39-win_amd64.whl", hash = "sha256:fd4028d570fa4b31b7b165d4a685942ae9cdc669f33741e388c01857d9723eab"}, + {file = "matplotlib-3.7.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2a9a3f4d6a7f88a62a6a18c7e6a84aedcaf4faf0708b4ca46d87b19f1b526f88"}, + {file = "matplotlib-3.7.5-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9b3fd853d4a7f008a938df909b96db0b454225f935d3917520305b90680579c"}, + {file = "matplotlib-3.7.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ad550da9f160737d7890217c5eeed4337d07e83ca1b2ca6535078f354e7675"}, + {file = "matplotlib-3.7.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:20da7924a08306a861b3f2d1da0d1aa9a6678e480cf8eacffe18b565af2813e7"}, + {file = "matplotlib-3.7.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b45c9798ea6bb920cb77eb7306409756a7fab9db9b463e462618e0559aecb30e"}, + {file = "matplotlib-3.7.5-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a99866267da1e561c7776fe12bf4442174b79aac1a47bd7e627c7e4d077ebd83"}, + {file = "matplotlib-3.7.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b6aa62adb6c268fc87d80f963aca39c64615c31830b02697743c95590ce3fbb"}, + {file = "matplotlib-3.7.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e530ab6a0afd082d2e9c17eb1eb064a63c5b09bb607b2b74fa41adbe3e162286"}, + {file = "matplotlib-3.7.5.tar.gz", hash = "sha256:1e5c971558ebc811aa07f54c7b7c677d78aa518ef4c390e14673a09e0860184a"}, +] + +[package.dependencies] +contourpy = ">=1.0.1" +cycler = ">=0.10" +fonttools = ">=4.22.0" +importlib-resources = {version = ">=3.2.0", markers = "python_version < \"3.10\""} +kiwisolver = ">=1.0.1" +numpy = ">=1.20,<2" +packaging = ">=20.0" +pillow = ">=6.2.0" +pyparsing = ">=2.3.1" +python-dateutil = ">=2.7" + [[package]] name = "matplotlib-inline" version = "0.1.7" @@ -1984,6 +2696,48 @@ files = [ griffe = ">=0.49" mkdocstrings = ">=0.25" +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "napalm" +version = "5.0.0" +description = "Network Automation and Programmability Abstraction Layer with Multivendor support" +optional = false +python-versions = "*" +files = [ + {file = "napalm-5.0.0-py2.py3-none-any.whl", hash = "sha256:458837932e527ca06a4bab7e600b0ca6e6bc3bb4b33fad9c9ef2befc7df6d2f5"}, + {file = "napalm-5.0.0.tar.gz", hash = "sha256:350ac3d74f2f10030dbae44d3395551d7e03ee25c65fa5eb8263a4e6f51f2c94"}, +] + +[package.dependencies] +cffi = ">=1.11.3" +jinja2 = "*" +junos-eznc = ">=2.7.0" +lxml = ">=4.3.0" +ncclient = "*" +netaddr = "*" +netmiko = ">=4.1.0" +netutils = ">=1.0.0" +paramiko = ">=2.6.0" +pyeapi = ">=1.0.2" +pyYAML = "*" +requests = ">=2.7.0" +scp = "*" +setuptools = ">=38.4.0" +textfsm = "*" +ttp = "*" +ttp-templates = "*" +typing-extensions = ">=4.3.0" + [[package]] name = "nautobot" version = "2.3.16" @@ -2013,8 +2767,8 @@ django-redis = ">=5.4.0,<5.5.0" django-silk = ">=5.1.0,<5.2.0" django-structlog = {version = ">=8.1.0,<9.0.0", extras = ["celery"]} django-tables2 = [ - {version = "2.7.0", markers = "python_version < \"3.9\""}, {version = ">=2.7.4,<2.8.0", markers = "python_version >= \"3.9\""}, + {version = "2.7.0", markers = "python_version < \"3.9\""}, ] django-taggit = ">=5.0.0,<5.1.0" django-timezone-field = ">=7.0,<7.1" @@ -2053,6 +2807,52 @@ napalm = ["napalm (>=4.1.0,<6.0.0)"] remote-storage = ["django-storages (==1.14.3)"] sso = ["social-auth-core[saml] (>=4.5.3,<4.6.0)"] +[[package]] +name = "nautobot-capacity-metrics" +version = "3.1.1" +description = "App to improve the instrumentation of Nautobot and expose additional metrics (Application Metrics, RQ Worker)." +optional = false +python-versions = "<3.13,>=3.8" +files = [ + {file = "nautobot_capacity_metrics-3.1.1-py3-none-any.whl", hash = "sha256:cba7108fc32473dd57e67e49e4c9de353837d0db63212e3dc9bed78ea6df57e6"}, + {file = "nautobot_capacity_metrics-3.1.1.tar.gz", hash = "sha256:3f54cbaca846fd89bd215829305e28877b596a4de081e785d22afd91f2ae90c2"}, +] + +[package.dependencies] +nautobot = ">=2.0.0,<3.0.0" + +[[package]] +name = "nautobot-plugin-nornir" +version = "2.1.0" +description = "Nautobot App that provides a shim layer to simplify using Nornir within other Nautobot Apps and Nautobot Jobs" +optional = false +python-versions = "<3.13,>=3.8" +files = [ + {file = "nautobot_plugin_nornir-2.1.0-py3-none-any.whl", hash = "sha256:aa50882b5fc729fb95e2d03383596a582f1b09419c8ec9c6db5f12cbb6f6ffa0"}, + {file = "nautobot_plugin_nornir-2.1.0.tar.gz", hash = "sha256:ea7ead4e52d27f349846d55bcdc00d6953f1bd03813e70a094f035a66bc863e7"}, +] + +[package.dependencies] +nautobot = ">=2.0.0,<3.0.0" +netutils = ">=1.6.0" +nornir-nautobot = ">=3.0.0,<4.0.0" + +[[package]] +name = "ncclient" +version = "0.6.16" +description = "Python library for NETCONF clients" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "ncclient-0.6.16.tar.gz", hash = "sha256:a16a351d8c234e3bbf3495577b63c96ae4adfcdf67f2d84194313473ea65b805"}, +] + +[package.dependencies] +lxml = ">=3.3.0" +paramiko = ">=1.15.0" +setuptools = ">0.6" +six = "*" + [[package]] name = "netaddr" version = "1.3.0" @@ -2067,6 +2867,27 @@ files = [ [package.extras] nicer-shell = ["ipython"] +[[package]] +name = "netmiko" +version = "4.4.0" +description = "Multi-vendor library to simplify legacy CLI connections to network devices" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "netmiko-4.4.0-py3-none-any.whl", hash = "sha256:2ff4683f013fac0f80715286c7d3250e89166aefc4421cb75d3ff483f2ebbbc0"}, + {file = "netmiko-4.4.0.tar.gz", hash = "sha256:25ff1237976aa3ff2cacf04949314638c899220a1675bd029e31b07ce20ce3b6"}, +] + +[package.dependencies] +cffi = ">=1.17.0rc1" +ntc-templates = ">=3.1.0" +paramiko = ">=2.9.5" +pyserial = ">=3.3" +pyyaml = ">=5.3" +scp = ">=0.13.6" +setuptools = ">=65.0.0" +textfsm = ">=1.1.3" + [[package]] name = "netutils" version = "1.11.0" @@ -2114,6 +2935,202 @@ files = [ {file = "nh3-0.2.20.tar.gz", hash = "sha256:9705c42d7ff88a0bea546c82d7fe5e59135e3d3f057e485394f491248a1f8ed5"}, ] +[[package]] +name = "nornir" +version = "3.4.1" +description = "Pluggable multi-threaded framework with inventory management to help operate collections of devices" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "nornir-3.4.1-py3-none-any.whl", hash = "sha256:db079cb95e3baf855530f4f40cb6ee93f93e1bf3cb74ac08180546adb1b987b8"}, + {file = "nornir-3.4.1.tar.gz", hash = "sha256:82a90a3478a3890bef8ad51b256fa966e6e4ca326cbe20a230918ef907cf68c3"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4,<5", markers = "python_version < \"3.10\""} +mypy_extensions = ">=1.0.0,<2.0.0" +"ruamel.yaml" = ">=0.17" + +[[package]] +name = "nornir-jinja2" +version = "0.2.0" +description = "Jinja2 plugins for nornir" +optional = false +python-versions = ">=3.6,<4.0" +files = [ + {file = "nornir_jinja2-0.2.0-py3-none-any.whl", hash = "sha256:0c446bec7a8492923d4eb9ca00fb327603b41bc35d5f0112843c048737b506b1"}, + {file = "nornir_jinja2-0.2.0.tar.gz", hash = "sha256:9ee5e725fe5543dcba4ec8b976804e9e88ecd356ea3b62bad97578cea0de1f75"}, +] + +[package.dependencies] +jinja2 = ">=2.11.2,<4" +nornir = ">=3,<4" + +[[package]] +name = "nornir-napalm" +version = "0.5.0" +description = "NAPALM's plugins for nornir" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "nornir_napalm-0.5.0-py3-none-any.whl", hash = "sha256:1a418bf0f5e38ac65894d474f81b50787dafe0aa1965c4fbd1b86d34d4374418"}, + {file = "nornir_napalm-0.5.0.tar.gz", hash = "sha256:4c95979eebe2475e7b8516411ad8e3205d2ff30e410d1dbdce785a55033d1130"}, +] + +[package.dependencies] +napalm = ">=5,<6" +nornir = ">=3,<4" + +[[package]] +name = "nornir-nautobot" +version = "3.1.0" +description = "Nornir Nautobot" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "nornir_nautobot-3.1.0-py3-none-any.whl", hash = "sha256:23197181c17fa6de503679490d04fdc7315133ec5ddc9b549eb0794af9da418f"}, + {file = "nornir_nautobot-3.1.0.tar.gz", hash = "sha256:5bc58d83650fb87aec456358205d455aaa5289345e2bc18f32d6bfa421eec63c"}, +] + +[package.dependencies] +httpx = ">=0.24.1,<0.25.0" +netutils = ">=1.6.0,<2.0.0" +nornir = ">=3.0.0,<4.0.0" +nornir-jinja2 = ">=0.2.0,<0.3.0" +nornir-napalm = ">=0.4.0,<1.0.0" +nornir-netmiko = ">=1,<2" +nornir-utils = ">=0,<1" +pynautobot = ">=2.0.0rc2" +requests = ">=2.25.1,<3.0.0" + +[package.extras] +mikrotik-driver = ["routeros-api (>=0.17.0,<0.18.0)"] + +[[package]] +name = "nornir-netmiko" +version = "1.0.1" +description = "Netmiko's plugins for Nornir" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "nornir_netmiko-1.0.1-py3-none-any.whl", hash = "sha256:eaee2944ad386b40c0719e8ac393ac63d531f44fb9a07d660bae7de430f12834"}, + {file = "nornir_netmiko-1.0.1.tar.gz", hash = "sha256:498546df001e0e499f10c5646d1356e361ccbb165b1335b89cfe8f19765e24d7"}, +] + +[package.dependencies] +netmiko = ">=4.0.0,<5.0.0" + +[[package]] +name = "nornir-utils" +version = "0.2.0" +description = "Collection of plugins and functions for nornir that don't require external dependencies" +optional = false +python-versions = ">=3.6.2,<4.0.0" +files = [ + {file = "nornir_utils-0.2.0-py3-none-any.whl", hash = "sha256:b4c430793a74f03affd5ff2d90abc8c67a28c7ff325f48e3a01a9a44ec71b844"}, + {file = "nornir_utils-0.2.0.tar.gz", hash = "sha256:4de6aaa35e5c1a98e1c84db84a008b0b1e974dc65d88484f2dcea3e30c95fbc2"}, +] + +[package.dependencies] +colorama = ">=0.4.3,<0.5.0" +nornir = ">=3,<4" + +[[package]] +name = "ntc-templates" +version = "7.5.0" +description = "TextFSM Templates for Network Devices, and Python wrapper for TextFSM's CliTable." +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "ntc_templates-7.5.0-py3-none-any.whl", hash = "sha256:9d7fb6467ccaaedf8e93e12106e4c46b1610e88d1bcae396b8c2f6a786d9db1c"}, + {file = "ntc_templates-7.5.0.tar.gz", hash = "sha256:b4b1693cd79ef0da5be0c66d58e3c6285d8d264d46832545765c0d394afed0aa"}, +] + +[package.dependencies] +textfsm = ">=1.1.0,<2.0.0" + +[[package]] +name = "numpy" +version = "1.24.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"}, + {file = "numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1"}, + {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4"}, + {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6"}, + {file = "numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc"}, + {file = "numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e"}, + {file = "numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810"}, + {file = "numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254"}, + {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7"}, + {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5"}, + {file = "numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d"}, + {file = "numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694"}, + {file = "numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61"}, + {file = "numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f"}, + {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e"}, + {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc"}, + {file = "numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2"}, + {file = "numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706"}, + {file = "numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400"}, + {file = "numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f"}, + {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9"}, + {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d"}, + {file = "numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835"}, + {file = "numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8"}, + {file = "numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef"}, + {file = "numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a"}, + {file = "numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2"}, + {file = "numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463"}, +] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + [[package]] name = "oauthlib" version = "3.2.2" @@ -2130,6 +3147,20 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "ordered-set" +version = "4.1.0" +description = "An OrderedSet is a custom MutableSet that remembers its order, so that every" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"}, + {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"}, +] + +[package.extras] +dev = ["black", "mypy", "pytest"] + [[package]] name = "packaging" version = "24.2" @@ -2156,6 +3187,27 @@ files = [ dev = ["pytest", "tox"] lint = ["black"] +[[package]] +name = "paramiko" +version = "3.5.0" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "paramiko-3.5.0-py3-none-any.whl", hash = "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9"}, + {file = "paramiko-3.5.0.tar.gz", hash = "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +pynacl = ">=1.5" + +[package.extras] +all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=2.0)"] + [[package]] name = "parso" version = "0.8.4" @@ -2418,7 +3470,6 @@ files = [ {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, @@ -2488,6 +3539,23 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +[[package]] +name = "pyeapi" +version = "1.0.4" +description = "Python Client for eAPI" +optional = false +python-versions = "*" +files = [ + {file = "pyeapi-1.0.4.tar.gz", hash = "sha256:05920677246823cd3dddf7d4d0f831fbc86fd416f356706a03bc56a291d78f3d"}, +] + +[package.dependencies] +netaddr = "*" + +[package.extras] +dev = ["check-manifest", "pep8", "pyflakes", "twine"] +test = ["coverage"] + [[package]] name = "pygments" version = "2.19.1" @@ -2615,6 +3683,75 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.19.1)"] +[[package]] +name = "pynacl" +version = "1.5.0" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, + {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, +] + +[package.dependencies] +cffi = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] + +[[package]] +name = "pynautobot" +version = "2.0.1" +description = "Nautobot API client library" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "pynautobot-2.0.1-py3-none-any.whl", hash = "sha256:14f9f05ef4c9f8918a56e4892c3badd3c25679aaf5cc6292adcebd7e1ba419c7"}, + {file = "pynautobot-2.0.1.tar.gz", hash = "sha256:de8bf725570baa5bee3a47e2a0de01605ab97e852e5f534b3d8e54a4ed6e2043"}, +] + +[package.dependencies] +requests = ">=2.30.0,<3.0.0" +urllib3 = ">=1.21.1,<1.27" + +[[package]] +name = "pyparsing" +version = "3.1.4" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, + {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +optional = false +python-versions = "*" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] + [[package]] name = "python-crontab" version = "3.2.0" @@ -3134,6 +4271,83 @@ files = [ {file = "rpds_py-0.20.1.tar.gz", hash = "sha256:e1791c4aabd117653530dccd24108fa03cc6baf21f58b950d0a73c3b3b29a350"}, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1"}, + {file = "ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.8" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.6" +files = [ + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, + {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, + {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, +] + [[package]] name = "ruff" version = "0.5.5" @@ -3171,6 +4385,20 @@ files = [ {file = "Rx-1.6.3.tar.gz", hash = "sha256:ca71b65d0fc0603a3b5cfaa9e33f5ba81e4aae10a58491133595088d7734b2da"}, ] +[[package]] +name = "scp" +version = "0.15.0" +description = "scp module for paramiko" +optional = false +python-versions = "*" +files = [ + {file = "scp-0.15.0-py2.py3-none-any.whl", hash = "sha256:9e7f721e5ac563c33eb0831d0f949c6342f1c28c3bdc3b02f39d77b5ea20df7e"}, + {file = "scp-0.15.0.tar.gz", hash = "sha256:f1b22e9932123ccf17eebf19e0953c6e9148f589f93d91b872941a696305c83f"}, +] + +[package.dependencies] +paramiko = "*" + [[package]] name = "setuptools" version = "75.3.0" @@ -3228,6 +4456,17 @@ files = [ {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "social-auth-app-django" version = "5.4.2" @@ -3342,6 +4581,21 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] +[[package]] +name = "textfsm" +version = "1.1.3" +description = "Python module for parsing semi-structured text into python tables." +optional = false +python-versions = "*" +files = [ + {file = "textfsm-1.1.3-py2.py3-none-any.whl", hash = "sha256:dcbeebc6a6137bed561c71a56344d752e6dbc04ae5ea309252cb70fb97ccc9cd"}, + {file = "textfsm-1.1.3.tar.gz", hash = "sha256:577ef278a9237f5341ae9b682947cefa4a2c1b24dbe486f94f2c95addc6504b5"}, +] + +[package.dependencies] +future = "*" +six = "*" + [[package]] name = "to-json-schema" version = "1.0.1" @@ -3456,6 +4710,56 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] +[[package]] +name = "transitions" +version = "0.9.2" +description = "A lightweight, object-oriented Python state machine implementation with many extensions." +optional = false +python-versions = "*" +files = [ + {file = "transitions-0.9.2-py2.py3-none-any.whl", hash = "sha256:f7b40c9b4a93869f36c4d1c33809aeb18cdeeb065fd1adba018ee39c3db216f3"}, + {file = "transitions-0.9.2.tar.gz", hash = "sha256:2f8490dbdbd419366cef1516032ab06d07ccb5839ef54905e842a472692d4204"}, +] + +[package.dependencies] +six = "*" + +[package.extras] +diagrams = ["pygraphviz"] +test = ["pytest"] + +[[package]] +name = "ttp" +version = "0.9.5" +description = "Template Text Parser" +optional = false +python-versions = ">=2.7,<4.0" +files = [ + {file = "ttp-0.9.5-py2.py3-none-any.whl", hash = "sha256:2c9fcf560b3f696e9fdd3554dc8e4622cbb10cac1d4fca13a7cf608c4a7fd137"}, + {file = "ttp-0.9.5.tar.gz", hash = "sha256:234414f4d3039d2d1cde09993f89f8db1b34d447f76c6a402555cefac2e59c4e"}, +] + +[package.extras] +docs = ["Sphinx (==4.3.0)", "readthedocs-sphinx-search (==0.1.1)", "sphinx_rtd_theme (==1.0.0)", "sphinxcontrib-applehelp (==1.0.1)", "sphinxcontrib-devhelp (==1.0.1)", "sphinxcontrib-htmlhelp (==2.0.0)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-napoleon (==0.7)", "sphinxcontrib-qthelp (==1.0.2)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-spelling (==7.2.1)"] +full = ["cerberus (>=1.3.0,<1.4.0)", "deepdiff (>=5.8.0,<5.9.0)", "jinja2 (>=3.0.0,<3.1.0)", "n2g (>=0.2.0,<0.3.0)", "openpyxl (>=3.0.0,<3.1.0)", "pyyaml (==6.0)", "tabulate (>=0.8.0,<0.9.0)", "ttp_templates (<1.0.0)", "yangson (>=1.4.0,<1.5.0)"] + +[[package]] +name = "ttp-templates" +version = "0.3.7" +description = "Template Text Parser Templates collections" +optional = false +python-versions = "<4.0,>=3.6" +files = [ + {file = "ttp_templates-0.3.7-py3-none-any.whl", hash = "sha256:2328304fb4c957ee60db6f301143e8a4556b22a12b3e2f30511e8ef97fc78f7e"}, + {file = "ttp_templates-0.3.7.tar.gz", hash = "sha256:f9103041a3683a0cb3811609ad990f679beadfc9a92c3e3fa05d6037414ad2bf"}, +] + +[package.dependencies] +ttp = ">=0.6.0" + +[package.extras] +docs = ["mkdocs (==1.2.4)", "mkdocs-material (==7.2.2)", "mkdocs-material-extensions (==1.0.1)", "mkdocstrings[python] (>=0.18.0,<0.19.0)", "pygments (==2.11)", "pymdown-extensions (==9.3)"] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -3491,20 +4795,19 @@ files = [ [[package]] name = "urllib3" -version = "2.2.3" +version = "1.26.20" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, + {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, + {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "vine" @@ -3663,6 +4966,24 @@ files = [ {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, ] +[[package]] +name = "xmldiff" +version = "2.7.0" +description = "Creates diffs of XML files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "xmldiff-2.7.0-py3-none-any.whl", hash = "sha256:c8020e6aa4aa9fa13c72e5bf0eeafd0be998b0ab55d78b008abc75fbfebaca27"}, + {file = "xmldiff-2.7.0.tar.gz", hash = "sha256:c0910b1f800366dd7ec62923e5d06e8b06a1bd9120569a1c27f4f2446b9c68a2"}, +] + +[package.dependencies] +lxml = ">=3.1.0" +setuptools = "*" + +[package.extras] +devenv = ["black", "coverage", "flake8", "zest.releaser[recommended]"] + [[package]] name = "yamllint" version = "1.35.1" @@ -3681,6 +5002,20 @@ pyyaml = "*" [package.extras] dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"] +[[package]] +name = "yamlordereddictloader" +version = "0.4.2" +description = "YAML loader and dumper for PyYAML allowing to keep keys order." +optional = false +python-versions = "*" +files = [ + {file = "yamlordereddictloader-0.4.2-py3-none-any.whl", hash = "sha256:dc048adb67026786cd24119bd71241f35bc8b0fd37d24b415c37bbc8049f9cd7"}, + {file = "yamlordereddictloader-0.4.2.tar.gz", hash = "sha256:36af2f6210fcff5da4fc4c12e1d815f973dceb41044e795e1f06115d634bca13"}, +] + +[package.dependencies] +pyyaml = "*" + [[package]] name = "zipp" version = "3.20.2" @@ -3706,4 +5041,4 @@ all = [] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.13" -content-hash = "f2041fa5a92502d80e47c6a6e762583dceabb072a9c36f52abed52a7ef2da478" +content-hash = "21528eed38f42921aa5bc3f5a9a8a1f07446965f212289876cbebd0d00e9033a"