diff --git a/.cookiecutter.json b/.cookiecutter.json index 2553384ac..fea6f0e7e 100644 --- a/.cookiecutter.json +++ b/.cookiecutter.json @@ -21,7 +21,7 @@ "_drift_manager": { "template": "https://github.com/nautobot/cookiecutter-nautobot-app.git", "template_dir": "nautobot-app", - "template_ref": "refs/tags/nautobot-app-v2.4.0", + "template_ref": "refs/tags/nautobot-app-v2.4.1", "cookie_dir": "", "branch_prefix": "drift-manager", "pull_request_strategy": "create", @@ -30,7 +30,7 @@ "poetry" ], "draft": false, - "baked_commit_ref": "671ef8a64a7bc40e991ade1dd64560d734f122bc" + "baked_commit_ref": "1c99457b09d05553ef7193e0dda021b2d1caa407" } } } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6359b6135..96906b287 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,9 @@ /nautobot_ssot/integrations/infoblox/ @qduk @jdrew82 @nautobot/plugin-ssot /nautobot_ssot/integrations/ipfabric/ @alhogan @nautobot/plugin-ssot /nautobot_ssot/integrations/itential/ @jtdub @nautobot/plugin-ssot +/nautobot_ssot/integrations/librenms/ @bile0026 @nautobot/plugin-ssot /nautobot_ssot/integrations/meraki/ @jdrew82 @nautobot/plugin-ssot /nautobot_ssot/integrations/servicenow/ @glennmatthews @qduk @nautobot/plugin-ssot /nautobot_ssot/integrations/slurpit/ @lpconsulting321 @pietos @nautobot/plugin-ssot +/nautobot_ssot/integrations/solarwinds/ @jdrew82 @nopg @nautobot/plugin-ssot + diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE/pull_request_template.md rename to .github/pull_request_template.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92f1bdba1..e318c00d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Linting: ruff format" run: "poetry run invoke ruff --action format" ruff-lint: @@ -36,6 +38,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Linting: ruff" run: "poetry run invoke ruff --action lint" check-docs-build: @@ -47,6 +51,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Check Docs Build" run: "poetry run invoke build-and-check-docs" poetry: @@ -58,6 +64,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Checking: poetry lock file" run: "poetry run invoke lock --check" yamllint: @@ -69,6 +77,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Linting: yamllint" run: "poetry run invoke yamllint" check-in-docker: @@ -91,6 +101,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Constrain Nautobot version and regenerate lock file" env: INVOKE_NAUTOBOT_SSOT_LOCAL: "true" @@ -146,6 +158,8 @@ jobs: uses: "actions/checkout@v4" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Constrain Nautobot version and regenerate lock file" env: INVOKE_NAUTOBOT_SSOT_LOCAL: "true" @@ -187,6 +201,8 @@ jobs: fetch-depth: "0" - name: "Setup environment" uses: "networktocode/gh-action-setup-poetry-environment@v6" + with: + poetry-version: "1.8.5" - name: "Check for changelog entry" run: | git fetch --no-tags origin +refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} diff --git a/LICENSE b/LICENSE index bf295f493..e923d1255 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ Apache Software License 2.0 -Copyright (c) 2024, Network to Code, LLC +Copyright (c) 2025, Network to Code, LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 9dd05c053..844e42ea5 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,11 @@ This Nautobot application framework includes the following integrations: - Infoblox - IPFabric - Itential +- LibreNMS - Cisco Meraki - ServiceNow - Slurpit +- SolarWinds Read more about integrations [here](https://docs.nautobot.com/projects/ssot/en/latest/user/integrations). To enable and configure integrations follow the instructions from [the install guide](https://docs.nautobot.com/projects/ssot/en/latest/admin/install/#integrations-configuration). @@ -92,9 +94,11 @@ The SSoT framework includes a number of integrations with external Systems of Re * Cisco DNA Center * Infoblox * Itential +* LibreNMS * Cisco Meraki * ServiceNow * Slurpit +* SolarWinds > Note that the Arista CloudVision integration is currently incompatible with the [Arista Labs](https://labs.arista.com/) environment due to a TLS issue. It has been confirmed to work in on-prem environments previously. diff --git a/development/creds.example.env b/development/creds.example.env index 49ec37daa..b7b31f0d5 100644 --- a/development/creds.example.env +++ b/development/creds.example.env @@ -36,6 +36,9 @@ NAUTOBOT_SSOT_CITRIX_ADM_PASSWORD="changeme" NAUTOBOT_SSOT_INFOBLOX_PASSWORD="changeme" +NAUTOBOT_SSOT_SOLARWINDS_USERNAME="admin" +NAUTOBOT_SSOT_SOLARWINDS_PASSWORD="changeme" + # ACI Credentials. Append friendly name to the end to identify each APIC. NAUTOBOT_APIC_BASE_URI_NTC=https://aci.cloud.networktocode.com NAUTOBOT_APIC_USERNAME_NTC=admin diff --git a/development/development.env b/development/development.env index 5f1b7ee2e..6382c9305 100644 --- a/development/development.env +++ b/development/development.env @@ -115,4 +115,12 @@ IPFABRIC_TIMEOUT=15 NAUTOBOT_SSOT_ENABLE_ITENTIAL="True" NAUTOBOT_SSOT_ENABLE_SLURPIT="False" -SLURPIT_HOST="https://sandbox.slurpit.io" \ No newline at end of file +SLURPIT_HOST="https://sandbox.slurpit.io" + + +NAUTOBOT_SSOT_ENABLE_LIBRENMS="False" +NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD="LibreNMS" +NAUTOBOT_SSOT_LIBRENMS_HOSTNAME_FIELD="sysName" # hostname or sysName + +NAUTOBOT_SSOT_ENABLE_SOLARWINDS="False" + diff --git a/development/docker-compose.dev.yml b/development/docker-compose.dev.yml index 3b5b20272..1edc5a7a9 100644 --- a/development/docker-compose.dev.yml +++ b/development/docker-compose.dev.yml @@ -45,10 +45,12 @@ services: volumes: - "./nautobot_config.py:/opt/nautobot/nautobot_config.py" - "../:/source" -# To expose postgres or redis to the host uncomment the following -# postgres: +# To expose postgres (5432), myql (3306) on db service or redis (6379) to the host uncomment the +# following. Ensure to match the 2 idented spaces which to have the service nested under services. +# db: # ports: # - "5432:5432" +# - "3306:3306" # redis: # ports: # - "6379:6379" diff --git a/development/docker-compose.mysql.yml b/development/docker-compose.mysql.yml index dbe31cba4..6751d7207 100644 --- a/development/docker-compose.mysql.yml +++ b/development/docker-compose.mysql.yml @@ -14,6 +14,13 @@ services: - "development.env" - "creds.env" - "development_mysql.env" + beat: + environment: + - "NAUTOBOT_DB_ENGINE=django.db.backends.mysql" + env_file: + - "development.env" + - "creds.env" + - "development_mysql.env" db: image: "mysql:8" command: diff --git a/development/nautobot_config.py b/development/nautobot_config.py index a6d7b7f19..6765c348f 100644 --- a/development/nautobot_config.py +++ b/development/nautobot_config.py @@ -4,7 +4,7 @@ import sys from nautobot.core.settings import * # noqa: F403 # pylint: disable=wildcard-import,unused-wildcard-import -from nautobot.core.settings_funcs import is_truthy, parse_redis_connection +from nautobot.core.settings_funcs import is_truthy # # Debug @@ -65,16 +65,8 @@ # # The django-redis cache is used to establish concurrent locks using Redis. -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": parse_redis_connection(redis_database=0), - "TIMEOUT": 300, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } -} +# Inherited from nautobot.core.settings +# CACHES = {....} # # Celery settings are not defined here because they can be overloaded with @@ -229,9 +221,11 @@ "enable_infoblox": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_INFOBLOX")), "enable_ipfabric": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_IPFABRIC")), "enable_itential": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_ITENTIAL")), + "enable_librenms": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_LIBRENMS", "false")), "enable_meraki": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_MERAKI")), "enable_servicenow": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_SERVICENOW")), "enable_slurpit": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_SLURPIT")), + "enable_solarwinds": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_SOLARWINDS")), "hide_example_jobs": is_truthy(os.getenv("NAUTOBOT_SSOT_HIDE_EXAMPLE_JOBS")), "device42_defaults": { "site_status": "Active", diff --git a/docs/admin/install.md b/docs/admin/install.md index 77646bd7c..7068bc8c5 100644 --- a/docs/admin/install.md +++ b/docs/admin/install.md @@ -97,6 +97,8 @@ Set up each integration using the specific guides: - [Infoblox](./integrations/infoblox_setup.md) - [IPFabric](./integrations/ipfabric_setup.md) - [Itential](./integrations/itential_setup.md) +- [LibreNMS](./integrations/librenms_setup.md) - [Cisco Meraki](./integrations/meraki_setup.md) - [ServiceNow](./integrations/servicenow_setup.md) - [Slurpit](./integrations/slurpit_setup.md) +- [SolarWinds](./integrations/solarwinds_setup.md) diff --git a/docs/admin/integrations/index.md b/docs/admin/integrations/index.md index 23d7582fa..8c9af843c 100644 --- a/docs/admin/integrations/index.md +++ b/docs/admin/integrations/index.md @@ -11,6 +11,8 @@ This Nautobot app supports the following integrations: - [Infoblox](./infoblox_setup.md) - [IPFabric](./ipfabric_setup.md) - [Itential](./itential_setup.md) +- [LibreNMS](./librenms_setup.md) - [Cisco Meraki](./meraki_setup.md) - [ServiceNow](./servicenow_setup.md) - [Slurpit](./slurpit_setup.md) +- [SolarWinds](./solarwinds_setup.md) diff --git a/docs/admin/integrations/librenms_setup.md b/docs/admin/integrations/librenms_setup.md new file mode 100644 index 000000000..5d37cc84c --- /dev/null +++ b/docs/admin/integrations/librenms_setup.md @@ -0,0 +1,46 @@ +# LibreNMS + +## Description + +This App will sync data from the LibreNMS API into Nautobot to create Device and IPAM inventory items. Most items will receive a custom field associated with them called "System of Record", which will be set to "LibreNMS" (or whatever you set the `NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD` environment variable to). These items are then the only ones managed by the LibreNMS SSoT App. Other items within the Nautobot instance will not be affected unless there's items with overlapping names. If an item exists in Nautobot by it's identifiers but it does not have the "System of Record" custom field on it, the item will be updated with "LibreNMS" (or `NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD` environment variable value) when the App runs. This way no duplicates are created, and the App will not delete any items that are not defined in the LibreNMS API data but were manually created in Nautobot. + +## Installation + +Before configuring the integration, please ensure, that `nautobot-ssot` app was [installed with LibreNMS integration extra dependencies](../install.md#install-guide). + +```shell +pip install nautobot-ssot[librenms] +``` + +## Configuration + +Once the SSoT package has been installed you simply need to enable the integration by setting `enable_librenms` to True. + +```python +PLUGINS = ["nautobot_ssot"] + +PLUGINS_CONFIG = { + "nautobot_ssot": { + # Other nautobot_ssot settings ommitted. + "enable_librenms": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_LIBRENMS", "true")), + } +} +``` + +### External Integrations + +#### LibreNMS as DataSource + +The way you add your LibreNMS server instance is through the "External Integrations" objects in Nautobot. First, create a secret in Nautobot with your LibreNMS API token using an Environment Variable (or sync via secrets provider). Then create a SecretsGroup object and select the Secret you just created and set the Access Type to `HTTP(S)` and the Secret Type to `Token`. + +Once this is created, go into the Extensibility Menu and select `External Integrations`. Add an External Intergration with the Remote URL being your LibreNMS server URL (including http(s)://), set the method to `GET`, and select any other headers/settings you might need for your specific instance. Select the secrets group you created as this will inject the API token. Once created, you will select this External Integration when you run the LibreNMS to Nautobot SSoT job. + +![LibreNMS External Integration](../../images/librenms-external-integration.png) + +#### LibreNMS as DataTarget + +NotYetImplemented + +### LibreNMS API + +An API key with global read-only permissions is the minimum needed to sync information from LibreNMS. diff --git a/docs/admin/integrations/solarwinds_setup.md b/docs/admin/integrations/solarwinds_setup.md new file mode 100644 index 000000000..23443c8d3 --- /dev/null +++ b/docs/admin/integrations/solarwinds_setup.md @@ -0,0 +1,25 @@ +# SolarWinds Integration Setup + +This guide will walk you through steps to set up the SolarWinds integration with the `nautobot_ssot` app. + +## Prerequisites + +Before configuring the integration, please ensure, that `nautobot-ssot` app was [installed with the SolarWinds integration extra dependencies](../install.md#install-guide). + +```shell +pip install nautobot-ssot[solarwinds] +``` + +## Configuration + +Access to your SolarWinds instance is defined using the [ExternalIntegration](https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/externalintegration/) model which allows you to utilize this integration with multiple instances concurrently. Please bear in mind that it will synchronize all data 1:1 with the specified instance to match exactly, meaning it will delete data missing from an instance. Each ExternalIntegration must specify a SecretsGroup with [Secrets](https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/secret/) that contain the SolarWinds administrator Username and Password to authenticate with. You can find Secrets and SecretsGroups available under the Secrets menu. + +Below is an example snippet from `nautobot_config.py` that demonstrates how to enable the SolarWinds integration: + +```python +PLUGINS_CONFIG = { + "nautobot_ssot": { + "enable_solarwinds": is_truthy(os.getenv("NAUTOBOT_SSOT_ENABLE_SOLARWINDS", "true")), + } +} +``` diff --git a/docs/admin/release_notes/version_3.4.md b/docs/admin/release_notes/version_3.4.md new file mode 100644 index 000000000..b78e54324 --- /dev/null +++ b/docs/admin/release_notes/version_3.4.md @@ -0,0 +1,39 @@ + +# v3.4 Release Notes + +This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Release Overview + +This release adds two new integrations to the project, one for Solarwinds Orion and one for LibreNMS! There are also a lot of bug fixes for various integrations. + +## [v3.4.0 (2025-01-14)](https://github.com/nautobot/nautobot-app-ssot/releases/tag/v3.4.0) + +### Added + +- [#631](https://github.com/nautobot/nautobot-app-ssot/issues/631) - Added integration with SolarWinds. +- [#636](https://github.com/nautobot/nautobot-app-ssot/issues/636) - Added LibreNMS integration. + +### Documentation + +- [#631](https://github.com/nautobot/nautobot-app-ssot/issues/631) - Added documentation for SolarWinds integration. + +### Fixed + +- [#597](https://github.com/nautobot/nautobot-app-ssot/issues/597) - Fixed ACI integration LocationType usage in CRUD operations to match Job device_site or specified APIC Location's LocationType. +- [#598](https://github.com/nautobot/nautobot-app-ssot/issues/598) - Swapped out `nautobot.extras.plugins.PluginTemplateExtension` for `TemplateExtension` +- [#621](https://github.com/nautobot/nautobot-app-ssot/issues/621) - Fixed ASN updates on Location objects. +- [#621](https://github.com/nautobot/nautobot-app-ssot/issues/621) - Fixed documentation on data normalization. +- [#624](https://github.com/nautobot/nautobot-app-ssot/issues/624) - Fixed Floors respecting location map for Building related changes. +- [#626](https://github.com/nautobot/nautobot-app-ssot/issues/626) - Fixed SoftwareVersion update on Devices in DNA Center integration. +- [#634](https://github.com/nautobot/nautobot-app-ssot/issues/634) - Fixed load locations on the source adapter for the ServiceNow integration when a site filter is applied. +- [#641](https://github.com/nautobot/nautobot-app-ssot/issues/641) - Fixed incorrectly nested imports within if block used for Device Lifecycle Models. +- [#643](https://github.com/nautobot/nautobot-app-ssot/issues/643) - Fixed DNA Center bug where empty Locations were imported. +- [#646](https://github.com/nautobot/nautobot-app-ssot/issues/646) - Fixed IPAddress assigned wrong parent Prefix in Citrix ADM. +- [#648](https://github.com/nautobot/nautobot-app-ssot/issues/648) - Fixed Citrix ADM deleting SoftwareVersion in use with ValidatedSoftware. +- [#648](https://github.com/nautobot/nautobot-app-ssot/issues/648) - Fixed Meraki deleting SoftwareVersion in use with ValidatedSoftware. +- [#650](https://github.com/nautobot/nautobot-app-ssot/issues/650) - Fixed Device floor name being incorrectly defined and including Building name when it shouldn't. + +### Housekeeping + +- [#1](https://github.com/nautobot/nautobot-app-ssot/issues/1) - Rebaked from the cookie `nautobot-app-v2.4.1`. diff --git a/docs/images/librenms-external-integration.png b/docs/images/librenms-external-integration.png new file mode 100644 index 000000000..6844e0662 Binary files /dev/null and b/docs/images/librenms-external-integration.png differ diff --git a/docs/images/solarwinds_dashboard.png b/docs/images/solarwinds_dashboard.png new file mode 100644 index 000000000..d2f34a55c Binary files /dev/null and b/docs/images/solarwinds_dashboard.png differ diff --git a/docs/images/solarwinds_detail-view.png b/docs/images/solarwinds_detail-view.png new file mode 100644 index 000000000..3dcc1abee Binary files /dev/null and b/docs/images/solarwinds_detail-view.png differ diff --git a/docs/images/solarwinds_enabled_job.png b/docs/images/solarwinds_enabled_job.png new file mode 100644 index 000000000..8f5174527 Binary files /dev/null and b/docs/images/solarwinds_enabled_job.png differ diff --git a/docs/images/solarwinds_external_integration.png b/docs/images/solarwinds_external_integration.png new file mode 100644 index 000000000..417181bc7 Binary files /dev/null and b/docs/images/solarwinds_external_integration.png differ diff --git a/docs/images/solarwinds_job_form.png b/docs/images/solarwinds_job_form.png new file mode 100644 index 000000000..52d05991f Binary files /dev/null and b/docs/images/solarwinds_job_form.png differ diff --git a/docs/images/solarwinds_job_list.png b/docs/images/solarwinds_job_list.png new file mode 100644 index 000000000..1c4932d7e Binary files /dev/null and b/docs/images/solarwinds_job_list.png differ diff --git a/docs/images/solarwinds_job_settings.png b/docs/images/solarwinds_job_settings.png new file mode 100644 index 000000000..2bd6dd855 Binary files /dev/null and b/docs/images/solarwinds_job_settings.png differ diff --git a/docs/images/solarwinds_jobresult.png b/docs/images/solarwinds_jobresult.png new file mode 100644 index 000000000..8256e8809 Binary files /dev/null and b/docs/images/solarwinds_jobresult.png differ diff --git a/docs/images/solarwinds_password_secret.png b/docs/images/solarwinds_password_secret.png new file mode 100644 index 000000000..46e77cb86 Binary files /dev/null and b/docs/images/solarwinds_password_secret.png differ diff --git a/docs/images/solarwinds_secretsgroup.png b/docs/images/solarwinds_secretsgroup.png new file mode 100644 index 000000000..e0d5b1cf6 Binary files /dev/null and b/docs/images/solarwinds_secretsgroup.png differ diff --git a/docs/images/solarwinds_username_secret.png b/docs/images/solarwinds_username_secret.png new file mode 100644 index 000000000..9cd9c8376 Binary files /dev/null and b/docs/images/solarwinds_username_secret.png differ diff --git a/docs/user/integrations/bootstrap.md b/docs/user/integrations/bootstrap.md index 94b89dade..8d881c006 100644 --- a/docs/user/integrations/bootstrap.md +++ b/docs/user/integrations/bootstrap.md @@ -19,6 +19,10 @@ NotYetImplemented ### Data structures +### General Notes on Data in the YAML file + +Data values are generally normalized in the app code. If a value is supposed to be a string and you want it to be blank or none, include a blank string (`""`) in the value. Integers should be left completely blank. Lists should be set to an empty list (`[]`), and dictionaries should be set to a blank dictionary (`{}`) in the yaml file. + #### global_settings.yml (see '../bootstrap/fixtures/global_settings.yml for examples of supported models) ```yaml diff --git a/docs/user/integrations/index.md b/docs/user/integrations/index.md index 1d5f499d2..17019dfce 100644 --- a/docs/user/integrations/index.md +++ b/docs/user/integrations/index.md @@ -11,6 +11,8 @@ This Nautobot app supports the following integrations: - [Infoblox](./infoblox.md) - [IPFabric](./ipfabric.md) - [Itential](./itential.md) +- [LibreNMS](./librenms.md) - [Cisco Meraki](./meraki.md) - [ServiceNow](./servicenow.md) - [Slurpit](./slurpit.md) +- [SolarWinds](./solarwinds.md) diff --git a/docs/user/integrations/librenms.md b/docs/user/integrations/librenms.md new file mode 100644 index 000000000..97e7fa682 --- /dev/null +++ b/docs/user/integrations/librenms.md @@ -0,0 +1,35 @@ +## Usage + +## Process + +### LibreNMS as DataSource + +The LibreNMS SSoT integration is built as part of the [Nautobot Single Source of Truth (SSoT)](https://github.com/nautobot/nautobot-app-ssot) app. the SSoT app enables Nautobot to be the aggregation point for data coming from multiple systems of record (SoR). + +#### Job Options + +- Debug: Additional Logging +- Librenms Server: External integration object pointing to the required LibreNMS instance. +- hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. +- sync_location_parents: Whether to lookup City and State to add parent locations for geo locations. +- tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS + +From LibreNMS into Nautobot, the app synchronizes devices, their interfaces, associated IP addresses, and Locations. Here is a table showing the data mappings when syncing from LibreNMS. + +| LibreNMS objects | Nautobot objects | +| ----------------------- | ---------------------------- | +| geo location | Location | +| device | Device | +| interface | Interface | +| device os | Platform/Manufacturer `*` | +| os version | Software/SoftwareImage | +| ip address | IPAddress | +| hardware | DeviceType | + + +`*` Device OS from LibreNMS is not standardized and therefore there is a mapping that can be updated in the `constants.py` file for the integration as more device manufacturers and platforms need to be added. + +### LibreNMS as DataTarget + +Not yet implemented. + diff --git a/docs/user/integrations/solarwinds.md b/docs/user/integrations/solarwinds.md new file mode 100644 index 000000000..6ebb9215f --- /dev/null +++ b/docs/user/integrations/solarwinds.md @@ -0,0 +1,110 @@ +# SolarWinds SSoT Integration + +The SolarWinds integration is built as part of the [Nautobot Single Source of Truth (SSoT)](https://github.com/nautobot/nautobot-app-ssot) app. The SSoT app enables Nautobot to be the aggregation point for data coming from multiple systems of record (SoR). + +From SolarWinds into Nautobot, it synchronizes the following objects: + +| SolarWinds | Nautobot | +| ----------------------- | ---------------------------- | +| Container | Location* | +| Devices | Devices | +| Vendor | Manufacturers | +| Model/DeviceType | DeviceTypes | +| Model/Vendor | Platforms | +| Versions | SoftwareVersions | +| Interfaces | Interfaces | +| IP Addresses | IP Addresses | + +## Usage + +Once the app is installed and configured, you will be able to perform an inventory ingestion from SolarWinds Orion into Nautobot. From the Nautobot SSoT Dashboard view (`/plugins/ssot/`), or via Apps -> Single Source of Truth -> Dashboard, SolarWinds will show as a Data Source. + +![Dashboard View](../../images/solarwinds_dashboard.png) + +From the Dashboard, you can also view more information about the App by clicking on the `SolarWinds to Nautobot` link and see the Detail view. This view will show the mappings of SolarWinds objects to Nautobot objects, the sync history, and other configuration details for the App: + +![Detail View](../../images/solarwinds_detail-view.png) + +In order to utilize this integration you must first enable the Job. You can find the available installed Jobs under Jobs -> Jobs: + +![Job List](../../images/solarwinds_job_list.png) + +To enable the Job you must click on the orange pencil icon to the right of the `SolarWinds to Nautobot` Job. You will be presented with the settings for the Job as shown below: + +![Job Settings](../../images/solarwinds_job_settings.png) + +You'll need to check the `Enabled` checkbox and then the `Update` button at the bottom of the page. You will then see that the play button next to the Job changes to blue and becomes functional, linking to the Job form. + +![Enabled Job](../../images/solarwinds_enabled_job.png) + +Once the Job is enabled, you'll need to manually create a few objects in Nautobot to use with the Job. First, you'll need to create a Secret that contains your SolarWinds username and Password for authenticating to your desired SolarWinds instance: + +![Username Secret](../../images/solarwinds_username_secret.png) + +![Password Secret](../../images/solarwinds_password_secret.png) + +Once the required Secrets are created, you'll need to create a SecretsGroup that pairs them together and defines the Access Type of HTTP(S) like shown below: + +![SolarWinds SecretsGroup](../../images/solarwinds_secretsgroup.png) + +With the SecretsGroup defined containing your instance credentials you'll then need to create an ExternalIntegration object to store the information about the SolarWinds instance you wish to synchronize with. + +![SolarWinds ExternalIntegration](../../images/solarwinds_external_integration.png) + +> The only required portions are the Name, Remote URL, Verify SSL, HTTP Method (GET), and Secrets Group. +- The External Integration will need it's `http_method` set to `GET`. +- Keep the `verify_ssl` setting in mind, uncheck this if you are using untrusted certificates + +Extra settings can be configured in the Extra Config section of your External Integration, example below: + +| Setting | Default | Description | +| --------------- | ------- | --------------------------------------------------------------------------------- | +| port | 17774 | TCP port used for communication to the API | +| retries | 5 | How many retries before considering the connection to Solarwinds failed | +| batch_size | 100 | How many nodes to include in queries, this can be lowered to prevent API timeouts | + +```json +{ + "port": 443, + "retries": 10, + "batch_size": 100 +} +``` + +With those configured, you will then need to ensure you have the Locations and Location Types defined to be used for the imported Devices. With those created, you can run the Job to start the synchronization: + +![Job Form](../../images/solarwinds_job_form.png) + +If you wish to just test the synchronization but not have any data created in Nautobot you'll want to select the `Dryrun` toggle. Clicking the `Debug` toggle will enable more verbose logging to inform you of what is occuring behind the scenes. After those toggles there are also dropdowns that allow you to specify the SolarWinds instance to synchronize with and to define the LocationType to use for the imported Devices from SolarWinds. In addition, there are also some optional settings on the Job form: + +- You can choose to pull all devices from a specific SolarWinds Container (and subcontainers), or you can use a SolarWinds CustomProperty. This CustomProperty should be a Boolean set to `True`, and assigned to all devices you wish to sync. Enter the name of this CustomProperty into the CustomProperty field. +- If pulling from CustomProperty, you must choose the Location to place devices using the Location Override option, and should still choose the Container or ALL Containers. +- If the LocationType that you specify for the imported Devices requires a parent LocationType to be assigned, you must also select the Parent LocationType. + + +In addition, there are a few methods provided to assign Roles to your imported Devices. You can choose a Default Role to be used for all Devices not mapped via a method below. + +The Role Matching Attribute can be set to DeviceType or Hostname. You then provide a `role_map` to associate specific DeviceTypes or Hostnames to a Role name. This map should be a standard python dictionary if using DeviceType. Regex can be used to match Hostnames. Examples below: + +```python title="Role_Map using DeviceType" +{ + "C8300": "ROUTER_ROLE", + "C9200": "SWITCH_ROLE" +} +``` +```python title="Role_Map using Hostname and Regex" +{ + "CORE.*": "ROUTER_ROLE", + "WLC.*": "WLC_ROLE" +} +``` + +- Finally there is an option to specify a Tenant to be assigned to the imported Devices, Prefixes, and IPAddresses. This is handy for cases where you have multiple SolarWinds instances that are used by differing business units. +!!! info + Tenant names will also be used as Namespace names for any IP Addresses and Prefixes created, these Namespaces must be created by you! + +Running this Job will redirect you to a `Nautobot Job Result` view. + +![JobResult View](../../images/solarwinds_jobresult.png) + +Once the Job has finished you can click on the `SSoT Sync Details` button at the top right of the Job Result page to see detailed information about the data that was synchronized from SolarWinds and the outcome of the sync Job. There are also more logs to check if you run into issues, that can be found under Apps -> Logs. diff --git a/mkdocs.yml b/mkdocs.yml index 9394f2310..3d71ac32a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -117,9 +117,11 @@ nav: - Infoblox: "user/integrations/infoblox.md" - IPFabric: "user/integrations/ipfabric.md" - Itential: "user/integrations/itential.md" + - LibreNMS: "user/integrations/librenms.md" - Cisco Meraki: "user/integrations/meraki.md" - ServiceNow: "user/integrations/servicenow.md" - Slurpit: "user/integrations/slurpit.md" + - SolarWinds: "user/integrations/solarwinds.md" - Modeling: "user/modeling.md" - Performance: "user/performance.md" - Frequently Asked Questions: "user/faq.md" @@ -137,14 +139,17 @@ nav: - Infoblox: "admin/integrations/infoblox_setup.md" - IPFabric: "admin/integrations/ipfabric_setup.md" - Itential: "admin/integrations/itential_setup.md" + - LibreNMS: "admin/integrations/librenms_setup.md" - Cisco Meraki: "admin/integrations/meraki_setup.md" - ServiceNow: "admin/integrations/servicenow_setup.md" + - SolarWinds: "admin/integrations/solarwinds_setup.md" - Slurpit: "admin/integrations/slurpit_setup.md" - Upgrade: "admin/upgrade.md" - Uninstall: "admin/uninstall.md" - Compatibility Matrix: "admin/compatibility_matrix.md" - Release Notes: - "admin/release_notes/index.md" + - v3.4: "admin/release_notes/version_3.4.md" - v3.3: "admin/release_notes/version_3.3.md" - v3.2: "admin/release_notes/version_3.2.md" - v3.1: "admin/release_notes/version_3.1.md" @@ -171,7 +176,7 @@ nav: - Debugging Jobs: "dev/debugging.md" - Contributing to the App: "dev/contributing.md" - Development Environment: "dev/dev_environment.md" - - Upgrading SSoT Apps: "dev/upgrade.md" + - Release Checklist: "dev/release_checklist.md" - Code Reference: - "dev/code_reference/index.md" - Package: "dev/code_reference/package.md" diff --git a/nautobot_ssot/__init__.py b/nautobot_ssot/__init__.py index 268842ac3..dc5d0baef 100644 --- a/nautobot_ssot/__init__.py +++ b/nautobot_ssot/__init__.py @@ -26,6 +26,7 @@ "nautobot_ssot_itential", "nautobot_ssot_meraki", "nautobot_ssot_servicenow", + "nautobot_ssot_solarwinds", ] @@ -102,13 +103,17 @@ class NautobotSSOTAppConfig(NautobotAppConfig): "dna_center_show_failures": True, "enable_aci": False, "enable_aristacv": False, + "enable_bootstrap": False, "enable_device42": False, "enable_dna_center": False, "enable_citrix_adm": False, "enable_infoblox": False, "enable_ipfabric": False, + "enable_librenms": False, + "enable_meraki": False, "enable_servicenow": False, "enable_slurpit": False, + "enable_solarwinds": False, "enable_itential": False, "hide_example_jobs": True, "ipfabric_api_token": "", diff --git a/nautobot_ssot/api/urls.py b/nautobot_ssot/api/urls.py index a45c5cad9..4c2e7b299 100644 --- a/nautobot_ssot/api/urls.py +++ b/nautobot_ssot/api/urls.py @@ -1,9 +1,12 @@ -"""Django urlpatterns declaration for nautobot_ssot API.""" +"""Django API urlpatterns declaration for nautobot_ssot app.""" + +from nautobot.apps.api import OrderedDefaultRouter from nautobot_ssot.integrations.utils import each_enabled_integration_module -app_name = "ssot" # pylint: disable=invalid-name urlpatterns = [] +router = OrderedDefaultRouter() +# add the name of your api endpoint, usually hyphenated model name in plural, e.g. "my-model-classes" def _add_integrations(): @@ -12,3 +15,5 @@ def _add_integrations(): _add_integrations() + +urlpatterns += router.urls diff --git a/nautobot_ssot/contrib/model.py b/nautobot_ssot/contrib/model.py index 3889607e5..f7bc65faa 100644 --- a/nautobot_ssot/contrib/model.py +++ b/nautobot_ssot/contrib/model.py @@ -59,7 +59,7 @@ def get_queryset(cls): @classmethod def _check_field(cls, name): """Check whether the given field name is defined on the diffsync (pydantic) model.""" - if name not in cls.model_fields: + if name not in cls.model_fields: # pylint: disable=unsupported-membership-test raise ObjectCrudException(f"Field {name} is not defined on the model.") def get_from_db(self): diff --git a/nautobot_ssot/filters.py b/nautobot_ssot/filters.py index bbebc69ea..b70e5f8af 100644 --- a/nautobot_ssot/filters.py +++ b/nautobot_ssot/filters.py @@ -1,39 +1,35 @@ """Filtering logic for Sync and SyncLogEntry records.""" -import django_filters -from django.db.models import Q -from nautobot.apps.filters import BaseFilterSet +from nautobot.apps.filters import BaseFilterSet, SearchFilter -from .models import Sync, SyncLogEntry +from nautobot_ssot import models -class SyncFilterSet(BaseFilterSet): - """Filter capabilities for SyncOverview instances.""" +class SyncFilterSet(BaseFilterSet): # pylint: disable=too-many-ancestors + """Filter for Sync.""" class Meta: - """Metaclass attributes of SyncFilter.""" + """Meta attributes for filter.""" - model = Sync - fields = ["dry_run", "job_result"] + model = models.Sync + # add any fields from the model that you would like to filter your searches by using those + fields = ["dry_run", "job_result"] # pylint: disable=nb-use-fields-all -class SyncLogEntryFilterSet(BaseFilterSet): + +class SyncLogEntryFilterSet(BaseFilterSet): # pylint: disable=too-many-ancestors """Filter capabilities for SyncLogEntry instances.""" - q = django_filters.CharFilter(method="search", label="Search") + q = SearchFilter( + filter_predicates={ + "diff": "icontains", + "message": "icontains", + "object_repr": "icontains", + } + ) class Meta: """Metaclass attributes of SyncLogEntryFilter.""" - model = SyncLogEntry - fields = ["sync", "action", "status", "synced_object_type"] - - def search(self, queryset, _name, value): - """String search of SyncLogEntry records.""" - if not value.strip(): - return queryset - return queryset.filter( - Q(diff__icontains=value) # pylint: disable=unsupported-binary-operation - | Q(message__icontains=value) - | Q(object_repr__icontains=value) - ) + model = models.SyncLogEntry + fields = ["sync", "action", "status", "synced_object_type"] # pylint: disable=nb-use-fields-all diff --git a/nautobot_ssot/forms.py b/nautobot_ssot/forms.py index f61d9a439..b777f2fae 100644 --- a/nautobot_ssot/forms.py +++ b/nautobot_ssot/forms.py @@ -1,8 +1,8 @@ """Forms for working with Sync and SyncLogEntry models.""" from django import forms -from nautobot.apps.forms import add_blank_choice -from nautobot.core.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin +from nautobot.apps.forms import BootstrapMixin, add_blank_choice +from nautobot.core.forms import BOOLEAN_WITH_BLANK_CHOICES from .choices import SyncLogEntryActionChoices, SyncLogEntryStatusChoices from .models import Sync, SyncLogEntry diff --git a/nautobot_ssot/integrations/aci/diffsync/models/nautobot.py b/nautobot_ssot/integrations/aci/diffsync/models/nautobot.py index 737ea1024..a64d36a1a 100644 --- a/nautobot_ssot/integrations/aci/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/aci/diffsync/models/nautobot.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import IntegrityError -from nautobot.dcim.models import ControllerManagedDeviceGroup, Location, LocationType, Manufacturer +from nautobot.dcim.models import ControllerManagedDeviceGroup, Location, Manufacturer from nautobot.dcim.models import Device as OrmDevice from nautobot.dcim.models import DeviceType as OrmDeviceType from nautobot.dcim.models import Interface as OrmInterface @@ -180,7 +180,12 @@ def create(cls, adapter, ids, attrs): serial=attrs["serial"], comments=attrs["comments"], controller_managed_device_group=ControllerManagedDeviceGroup.objects.get(name=attrs["controller_group"]), - location=Location.objects.get(name=ids["site"], location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=ids["site"], + location_type=adapter.job.device_site.location_type + if adapter.job.device_site + else adapter.job.apic.location.location_type, + ), status=Status.objects.get(name="Active"), ) @@ -195,7 +200,12 @@ def update(self, attrs): """Update Device object in Nautobot.""" _device = OrmDevice.objects.get( name=self.name, - location=Location.objects.get(name=self.site, location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=self.site, + location_type=self.adapter.job.device_site.location_type + if self.adapter.job.device_site + else self.adapter.job.apic.location.location_type, + ), ) if attrs.get("serial"): _device.serial = attrs["serial"] @@ -222,7 +232,12 @@ def delete(self): super().delete() _device = OrmDevice.objects.get( name=self.name, - location=Location.objects.get(name=self.site, location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=self.site, + location_type=self.adapter.job.device_site.location_type + if self.adapter.job.device_site + else self.adapter.job.apic.location.location_type, + ), ) self.adapter.objects_to_delete["device"].append(_device) # pylint: disable=protected-access return self @@ -276,7 +291,12 @@ def create(cls, adapter, ids, attrs): name=ids["name"], device=OrmDevice.objects.get( name=ids["device"], - location=Location.objects.get(name=ids["site"], location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=ids["site"], + location_type=adapter.job.device_site.location_type + if adapter.job.device_site + else adapter.job.apic.location.location_type, + ), ), description=attrs["description"], status=Status.objects.get(name="Active") if attrs["state"] == "up" else Status.objects.get(name="Failed"), @@ -300,7 +320,12 @@ def update(self, attrs): name=self.name, device=OrmDevice.objects.get( name=self.device, - location=Location.objects.get(name=self.site, location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=self.site, + location_type=self.adapter.job.device_site.location_type + if self.adapter.job.device_site + else self.adapter.job.apic.location.location_type, + ), ), ) if attrs.get("description"): @@ -330,7 +355,12 @@ def delete(self): try: device = OrmDevice.objects.get( name=self.device, - location=Location.objects.get(name=self.site, location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=self.site, + location_type=self.adapter.job.device_site.location_type + if self.adapter.job.device_site + else self.adapter.job.apic.location.location_type, + ), ) except OrmDevice.DoesNotExist: self.adapter.job.logger.warning( @@ -398,7 +428,12 @@ def create(cls, adapter, ids, attrs): if attrs["device"]: device = OrmDevice.objects.get( name=_device, - location=Location.objects.get(name=ids["site"], location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=ids["site"], + location_type=adapter.job.device_site.location_type + if adapter.job.device_site + else adapter.job.apic.location.location_type, + ), ) device.primary_ip4 = OrmIPAddress.objects.get(address=ids["address"]) device.save() @@ -467,7 +502,12 @@ def create(cls, adapter, ids, attrs): description=attrs["description"], namespace=Namespace.objects.get(name=attrs["namespace"]), tenant=OrmTenant.objects.get(name=attrs["vrf_tenant"]), - location=Location.objects.get(name=ids["site"], location_type=LocationType.objects.get(name="Site")), + location=Location.objects.get( + name=ids["site"], + location_type=adapter.job.device_site.location_type + if adapter.job.device_site + else adapter.job.apic.location.location_type, + ), ) if not created: diff --git a/nautobot_ssot/integrations/bootstrap/diffsync/models/bootstrap.py b/nautobot_ssot/integrations/bootstrap/diffsync/models/bootstrap.py index 86729e066..88edaa72a 100755 --- a/nautobot_ssot/integrations/bootstrap/diffsync/models/bootstrap.py +++ b/nautobot_ssot/integrations/bootstrap/diffsync/models/bootstrap.py @@ -402,148 +402,154 @@ def delete(self): return self -if LIFECYCLE_MGMT: +class BootstrapNamespace(Namespace): + """Bootstrap implementation of Bootstrap Namespace model.""" - class BootstrapSoftware(Software): - """Bootstrap implementation of Bootstrap Software model.""" + @classmethod + def create(cls, diffsync, ids, attrs): + """Create Namespace in Bootstrap from BootstrapNamespace object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) - @classmethod - def create(cls, diffsync, ids, attrs): - """Create Software in Bootstrap from BootstrapSoftware object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + def update(self, attrs): + """Update Namespace in Bootstrap from BootstrapNamespace object.""" + return super().update(attrs) - def update(self, attrs): - """Update Software in Bootstrap from BootstrapSoftware object.""" - return super().update(attrs) + def delete(self): + """Delete Namespace in Bootstrap from BootstrapNamespace object.""" + return self - def delete(self): - """Delete Software in Bootstrap from BootstrapSoftware object.""" - return self - class BootstrapSoftwareImage(SoftwareImage): - """Bootstrap implementation of Bootstrap SoftwareImage model.""" +class BootstrapRiR(RiR): + """Bootstrap implementation of Bootstrap RiR model.""" - @classmethod - def create(cls, diffsync, ids, attrs): - """Create SoftwareImage in Bootstrap from BootstrapSoftwareImage object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + @classmethod + def create(cls, diffsync, ids, attrs): + """Create RiR in Bootstrap from BootstrapRiR object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) - def update(self, attrs): - """Update SoftwareImage in Bootstrap from BootstrapSoftwareImage object.""" - return super().update(attrs) + def update(self, attrs): + """Update RiR in Bootstrap from BootstrapRiR object.""" + return super().update(attrs) - def delete(self): - """Delete SoftwareImage in Bootstrap from BootstrapSoftwareImage object.""" - return self + def delete(self): + """Delete RiR in Bootstrap from BootstrapRiR object.""" + return self - class BootstrapValidatedSoftware(ValidatedSoftware): - """Bootstrap implementation of Bootstrap ValidatedSoftware model.""" - @classmethod - def create(cls, diffsync, ids, attrs): - """Create ValidatedSoftware in Bootstrap from BootstrapValidatedSoftware object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) +class BootstrapVLANGroup(VLANGroup): + """Bootstrap implementation of Bootstrap VLANGroup model.""" - def update(self, attrs): - """Update ValidatedSoftware in Bootstrap from BootstrapValidatedSoftware object.""" - return super().update(attrs) + @classmethod + def create(cls, diffsync, ids, attrs): + """Create VLANGroup in Bootstrap from BootstrapVLANGroup object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) - def delete(self): - """Delete ValidatedSoftware in Bootstrap from BootstrapValidatedSoftware object.""" - return self + def update(self, attrs): + """Update VLANGroup in Bootstrap from BootstrapVLANGroup object.""" + return super().update(attrs) - class BootstrapNamespace(Namespace): - """Bootstrap implementation of Bootstrap Namespace model.""" + def delete(self): + """Delete VLANGroup in Bootstrap from BootstrapVLANGroup object.""" + return self - @classmethod - def create(cls, diffsync, ids, attrs): - """Create Namespace in Bootstrap from BootstrapNamespace object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) - def update(self, attrs): - """Update Namespace in Bootstrap from BootstrapNamespace object.""" - return super().update(attrs) +class BootstrapVLAN(VLAN): + """Bootstrap implementation of Bootstrap VLAN model.""" - def delete(self): - """Delete Namespace in Bootstrap from BootstrapNamespace object.""" - return self + @classmethod + def create(cls, diffsync, ids, attrs): + """Create VLAN in Bootstrap from BootstrapVLAN object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) - class BootstrapRiR(RiR): - """Bootstrap implementation of Bootstrap RiR model.""" + def update(self, attrs): + """Update VLAN in Bootstrap from BootstrapVLAN object.""" + return super().update(attrs) - @classmethod - def create(cls, diffsync, ids, attrs): - """Create RiR in Bootstrap from BootstrapRiR object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + def delete(self): + """Delete VLAN in Bootstrap from BootstrapVLAN object.""" + return self - def update(self, attrs): - """Update RiR in Bootstrap from BootstrapRiR object.""" - return super().update(attrs) - def delete(self): - """Delete RiR in Bootstrap from BootstrapRiR object.""" - return self +class BootstrapVRF(VRF): + """Bootstrap implementation of Bootstrap VRF model.""" - class BootstrapVLANGroup(VLANGroup): - """Bootstrap implementation of Bootstrap VLANGroup model.""" + @classmethod + def create(cls, diffsync, ids, attrs): + """Create VRF in Bootstrap from BootstrapVRF object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) - @classmethod - def create(cls, diffsync, ids, attrs): - """Create VLANGroup in Bootstrap from BootstrapVLANGroup object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + def update(self, attrs): + """Update VRF in Bootstrap from BootstrapVRF object.""" + return super().update(attrs) - def update(self, attrs): - """Update VLANGroup in Bootstrap from BootstrapVLANGroup object.""" - return super().update(attrs) + def delete(self): + """Delete VRF in Bootstrap from BootstrapVRF object.""" + return self - def delete(self): - """Delete VLANGroup in Bootstrap from BootstrapVLANGroup object.""" - return self - class BootstrapVLAN(VLAN): - """Bootstrap implementation of Bootstrap VLAN model.""" +class BootstrapPrefix(Prefix): + """Bootstrap implementation of Bootstrap Prefix model.""" + + @classmethod + def create(cls, diffsync, ids, attrs): + """Create Prefix in Bootstrap from BootstrapPrefix object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update Prefix in Bootstrap from BootstrapPrefix object.""" + return super().update(attrs) + + def delete(self): + """Delete Prefix in Bootstrap from BootstrapPrefix object.""" + return self + + +if LIFECYCLE_MGMT: + + class BootstrapSoftware(Software): + """Bootstrap implementation of Bootstrap Software model.""" @classmethod def create(cls, diffsync, ids, attrs): - """Create VLAN in Bootstrap from BootstrapVLAN object.""" + """Create Software in Bootstrap from BootstrapSoftware object.""" return super().create(diffsync=diffsync, ids=ids, attrs=attrs) def update(self, attrs): - """Update VLAN in Bootstrap from BootstrapVLAN object.""" + """Update Software in Bootstrap from BootstrapSoftware object.""" return super().update(attrs) def delete(self): - """Delete VLAN in Bootstrap from BootstrapVLAN object.""" + """Delete Software in Bootstrap from BootstrapSoftware object.""" return self - class BootstrapVRF(VRF): - """Bootstrap implementation of Bootstrap VRF model.""" + class BootstrapSoftwareImage(SoftwareImage): + """Bootstrap implementation of Bootstrap SoftwareImage model.""" @classmethod def create(cls, diffsync, ids, attrs): - """Create VRF in Bootstrap from BootstrapVRF object.""" + """Create SoftwareImage in Bootstrap from BootstrapSoftwareImage object.""" return super().create(diffsync=diffsync, ids=ids, attrs=attrs) def update(self, attrs): - """Update VRF in Bootstrap from BootstrapVRF object.""" + """Update SoftwareImage in Bootstrap from BootstrapSoftwareImage object.""" return super().update(attrs) def delete(self): - """Delete VRF in Bootstrap from BootstrapVRF object.""" + """Delete SoftwareImage in Bootstrap from BootstrapSoftwareImage object.""" return self - class BootstrapPrefix(Prefix): - """Bootstrap implementation of Bootstrap Prefix model.""" + class BootstrapValidatedSoftware(ValidatedSoftware): + """Bootstrap implementation of Bootstrap ValidatedSoftware model.""" @classmethod def create(cls, diffsync, ids, attrs): - """Create Prefix in Bootstrap from BootstrapPrefix object.""" + """Create ValidatedSoftware in Bootstrap from BootstrapValidatedSoftware object.""" return super().create(diffsync=diffsync, ids=ids, attrs=attrs) def update(self, attrs): - """Update Prefix in Bootstrap from BootstrapPrefix object.""" + """Update ValidatedSoftware in Bootstrap from BootstrapValidatedSoftware object.""" return super().update(attrs) def delete(self): - """Delete Prefix in Bootstrap from BootstrapPrefix object.""" + """Delete ValidatedSoftware in Bootstrap from BootstrapValidatedSoftware object.""" return self diff --git a/nautobot_ssot/integrations/bootstrap/diffsync/models/nautobot.py b/nautobot_ssot/integrations/bootstrap/diffsync/models/nautobot.py index 76081a68a..3b5de750c 100755 --- a/nautobot_ssot/integrations/bootstrap/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/bootstrap/diffsync/models/nautobot.py @@ -575,7 +575,7 @@ def update(self, attrs): if "facility" in attrs: _update_location.facility = attrs["facility"] if "asn" in attrs: - _update_location.asn = attrs["location"] + _update_location.asn = attrs["asn"] if "time_zone" in attrs: if attrs["time_zone"]: _timezone = pytz.timezone(attrs["time_zone"]) diff --git a/nautobot_ssot/integrations/citrix_adm/diffsync/adapters/citrix_adm.py b/nautobot_ssot/integrations/citrix_adm/diffsync/adapters/citrix_adm.py index ff51d96a2..29187f9d6 100644 --- a/nautobot_ssot/integrations/citrix_adm/diffsync/adapters/citrix_adm.py +++ b/nautobot_ssot/integrations/citrix_adm/diffsync/adapters/citrix_adm.py @@ -9,6 +9,7 @@ from nautobot.extras.choices import SecretsGroupAccessTypeChoices, SecretsGroupSecretTypeChoices from nautobot.extras.models import ExternalIntegration, Job from nautobot.tenancy.models import Tenant +from netutils.ip import is_ip_within from nautobot_ssot.integrations.citrix_adm.constants import DEVICETYPE_MAP from nautobot_ssot.integrations.citrix_adm.diffsync.models.citrix_adm import ( @@ -291,6 +292,25 @@ def load_address_to_interface(self, address: str, device: str, port: str, primar new_map = self.ip_on_intf(address=address, device=device, port=port, primary=primary, uuid=None) self.add(new_map) + def find_closer_parent_prefix(self) -> None: + """Find more accurate parent Prefix for loaded IPAddresses.""" + for ipaddr in self.get_all(obj="address"): + for prefix in self.get_all(obj="prefix"): + # check if prefixes are both IPv4 or IPv6 + if (":" in ipaddr.prefix and ":" not in prefix.prefix) or ( + ":" in prefix.prefix and ":" not in ipaddr.prefix + ): + continue + if not is_ip_within(ipaddr.prefix, prefix.prefix): + host_addr = ipaddr.address.split("/")[0] + if is_ip_within(host_addr, prefix.prefix): + if self.job.debug: + self.job.logger.debug( + "More specific Prefix %s found for IPAddress %s", prefix.prefix, ipaddr.address + ) + ipaddr.prefix = prefix.prefix + self.update(ipaddr) + def load(self): """Load data from Citrix ADM into DiffSync models.""" for instance in self.instances: @@ -321,6 +341,7 @@ def load(self): self.create_port_map() self.load_ports() self.load_addresses() + self.find_closer_parent_prefix() self.conn.logout() else: diff --git a/nautobot_ssot/integrations/citrix_adm/diffsync/models/nautobot.py b/nautobot_ssot/integrations/citrix_adm/diffsync/models/nautobot.py index 4adecf80f..623a25226 100644 --- a/nautobot_ssot/integrations/citrix_adm/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/citrix_adm/diffsync/models/nautobot.py @@ -79,8 +79,14 @@ def create(cls, adapter, ids, attrs): def delete(self): """Delete SoftwareVersion in Nautobot from NautobotOSVersion object.""" ver = SoftwareVersion.objects.get(id=self.uuid) - super().delete() - ver.delete() + if hasattr(ver, "validatedsoftwarelcm_set"): + if ver.validatedsoftwarelcm_set.count() != 0: + self.adapter.job.logger.warning( + f"SoftwareVersion {ver.version} for {ver.platform.name} is used with a ValidatedSoftware so won't be deleted." + ) + else: + super().delete() + ver.delete() return self @@ -255,7 +261,7 @@ def create(cls, adapter, ids, attrs): """Create IP Address in Nautobot from NautobotAddress object.""" new_ip = IPAddress( address=ids["address"], - parent=Prefix.objects.filter(network__net_contains=ids["address"].split("/")[0]).last(), + parent=Prefix.objects.get(prefix=ids["prefix"]), status=Status.objects.get(name="Active"), namespace=( Namespace.objects.get_or_create(name=attrs["tenant"])[0] diff --git a/nautobot_ssot/integrations/dna_center/diffsync/adapters/dna_center.py b/nautobot_ssot/integrations/dna_center/diffsync/adapters/dna_center.py index 10496916f..b9a147e56 100644 --- a/nautobot_ssot/integrations/dna_center/diffsync/adapters/dna_center.py +++ b/nautobot_ssot/integrations/dna_center/diffsync/adapters/dna_center.py @@ -55,6 +55,8 @@ def __init__(self, *args, job, sync=None, client: DnaCenterClient, tenant: Tenan self.conn = client self.failed_import_devices = [] self.dnac_location_map = {} + self.building_map = {} + self.floors = [] self.tenant = tenant def load_locations(self): @@ -62,13 +64,102 @@ def load_locations(self): self.load_controller_locations() locations = self.conn.get_locations() if locations: - # to ensure we process locations in the appropriate order we need to split them into their own list of locations - self.dnac_location_map = self.build_dnac_location_map(locations) - _, buildings, floors = self.parse_and_sort_locations(locations) - self.load_buildings(buildings) - self.load_floors(floors) + self.floors = self.build_dnac_location_map(locations) else: - self.job.logger.error("No location data was returned from DNAC. Unable to proceed.") + self.job.logger.error("No location data was returned from DNA Center. Unable to proceed.") + + def build_dnac_location_map(self, locations: List[dict]): # pylint: disable=too-many-statements, too-many-branches + """Build out the DNA Center location structure based off DNAC information or Job location_map field. + + Args: + locations (List[dict]): List of Locations from DNA Center to be processed. + """ + # build initial mapping of IDs to location name + for location in locations: + if ( + not settings.PLUGINS_CONFIG["nautobot_ssot"].get("dna_center_import_global") + and location["name"] == "Global" + ): + continue + if location["name"] in self.job.location_map and self.job.location_map[location["name"]].get("name"): + loc_name = self.job.location_map[location["name"]]["name"] + else: + loc_name = location["name"] + + self.dnac_location_map[location["id"]] = { + "name": loc_name, + "loc_type": "area", + "parent": None, + "parent_of_parent": None, + } + + # add parent name for each location + for location in locations: # pylint: disable=too-many-nested-blocks + loc_id = location["id"] + loc_name = location["name"] + parent_id, parent_name = None, None + if location.get("parentId"): + parent_id = location["parentId"] + if self.dnac_location_map.get(parent_id): + parent_name = self.dnac_location_map[parent_id]["name"] + if not settings.PLUGINS_CONFIG["nautobot_ssot"].get("dna_center_import_global"): + if loc_name == "Global": + continue + if parent_name == "Global": + parent_name = None + if location["name"] in self.job.location_map and self.job.location_map[location["name"]].get("parent"): + parent_name = self.job.location_map[location["name"]]["parent"] + self.dnac_location_map[loc_id]["parent"] = parent_name + + # add parent of parent to the mapping + floors = [] + for location in locations: # pylint: disable=too-many-nested-blocks + if ( + not settings.PLUGINS_CONFIG["nautobot_ssot"].get("dna_center_import_global") + and location["name"] == "Global" + ): + continue + loc_id = location["id"] + loc_name = location["name"] + parent_id = location.get("parentId") + if self.dnac_location_map.get(parent_id): + self.dnac_location_map[loc_id]["parent_of_parent"] = self.dnac_location_map[parent_id]["parent"] + parent_name = self.dnac_location_map[loc_id]["parent"] + for info in location["additionalInfo"]: + if info["attributes"].get("type"): + self.dnac_location_map[loc_id]["loc_type"] = info["attributes"]["type"] + if info["attributes"]["type"] in ["area", "building"]: + if info["attributes"]["type"] == "building": + self.building_map[loc_id] = location + if self.job.location_map.get(parent_name) and self.job.location_map[parent_name].get( + "parent" + ): + self.dnac_location_map[loc_id]["parent_of_parent"] = self.job.location_map[parent_name][ + "parent" + ] + if self.job.location_map.get(loc_name): + if self.job.location_map[loc_name].get("parent"): + self.dnac_location_map[loc_id]["parent"] = self.job.location_map[loc_name]["parent"] + if self.job.location_map[loc_name].get("area_parent"): + self.dnac_location_map[loc_id]["parent_of_parent"] = self.job.location_map[loc_name][ + "area_parent" + ] + elif info["attributes"]["type"] == "floor": + floors.append(location) + if self.dnac_location_map.get(parent_id): + self.dnac_location_map[loc_id]["parent"] = self.dnac_location_map[parent_id]["name"] + self.dnac_location_map[loc_id]["parent_of_parent"] = self.dnac_location_map[parent_id][ + "parent" + ] + if self.job.location_map.get(parent_name) and self.dnac_location_map[parent_id].get("name"): + self.dnac_location_map[loc_id]["parent"] = self.dnac_location_map[parent_id]["name"] + if self.job.location_map.get(parent_name) and self.dnac_location_map[parent_id].get("parent"): + self.dnac_location_map[loc_id]["parent_of_parent"] = self.dnac_location_map[parent_id][ + "parent" + ] + if self.job.debug: + self.job.logger.debug(f"Generated DNAC Location Map: {self.dnac_location_map}") + return floors def load_controller_locations(self): """Load location data for Controller specified in Job form.""" @@ -158,136 +249,59 @@ def load_area(self, area: str, area_parent: Optional[str] = None): """ self.get_or_instantiate(self.area, ids={"name": area, "parent": area_parent}, attrs={"uuid": None}) - def load_buildings(self, buildings: List[dict]): + def load_building(self, building: dict, area_name: Optional[str] = None, area_parent_name: Optional[str] = None): """Load building data from DNAC into DiffSync model. Args: - buildings (List[dict]): List of dictionaries containing location information about a building. + building (dict): Dictionary containing location information about a building. + area_name (str): Parent area for building. + area_parent_name (str): Parent of parent area for building. """ - for location in buildings: - if self.job.debug: - self.job.logger.info(f"Loading {self.job.building_loctype.name} {location['name']}. {location}") - bldg_name = location["name"] - _area, _area_parent = None, None - if bldg_name in self.job.location_map and "parent" in self.job.location_map[bldg_name]: - _area = self.job.location_map[bldg_name]["parent"] - if "area_parent" in self.job.location_map[bldg_name]: - _area_parent = self.job.location_map[bldg_name]["area_parent"] - if "name" in self.job.location_map[bldg_name]: - bldg_name = self.job.location_map[bldg_name]["name"] - elif location["parentId"] in self.dnac_location_map: - _area = self.dnac_location_map[location["parentId"]]["name"] - _area_parent = self.dnac_location_map[location["parentId"]]["parent"] - if _area in self.job.location_map and ( - "parent" in self.job.location_map[_area] and bldg_name not in self.job.location_map - ): - _area_parent = self.job.location_map[_area]["parent"] - if not settings.PLUGINS_CONFIG["nautobot_ssot"].get("dna_center_import_global"): - if _area == "Global": - _area = None - if _area_parent == "Global": - _area_parent = None - if _area: - self.load_area(area=_area, area_parent=_area_parent) - address, _ = self.conn.find_address_and_type(info=location["additionalInfo"]) - latitude, longitude = self.conn.find_latitude_and_longitude(info=location["additionalInfo"]) - _, loaded = self.get_or_instantiate( - self.building, - ids={"name": bldg_name, "area": _area}, - attrs={ - "address": address if address else "", - "area_parent": _area_parent, - "latitude": latitude[:9].rstrip("0"), - "longitude": longitude[:7].rstrip("0"), - "tenant": self.tenant.name if self.tenant else None, - "uuid": None, - }, + bldg_name = self.dnac_location_map[building["id"]]["name"] + if self.job.debug: + self.job.logger.info( + f"Loading {self.job.building_loctype.name} {bldg_name} in {area_name} with parent {area_parent_name}. {building}" ) - if not loaded: - self.job.logger.warning(f"{self.job.building_loctype.name} {bldg_name} already loaded so skipping.") + address, _ = self.conn.find_address_and_type(info=building["additionalInfo"]) + latitude, longitude = self.conn.find_latitude_and_longitude(info=building["additionalInfo"]) + self.get_or_instantiate( + self.building, + ids={"name": bldg_name, "area": area_name}, + attrs={ + "address": address if address else "", + "area_parent": area_parent_name, + "latitude": latitude[:9].rstrip("0"), + "longitude": longitude[:7].rstrip("0"), + "tenant": self.tenant.name if self.tenant else None, + "uuid": None, + }, + ) - def load_floors(self, floors: List[dict]): + def load_floor(self, floor_name: str, bldg_name: str, area_name: str): """Load floor data from DNAC into DiffSync model. Args: - floors (List[dict]): List of dictionaries containing location information about a floor. - """ - for location in floors: - if self.job.debug: - self.job.logger.info(f"Loading floor {location['name']}. {location}") - area_name = None - if location["parentId"] in self.dnac_location_map: - bldg_name = self.dnac_location_map[location["parentId"]]["name"] - area_name = self.dnac_location_map[location["parentId"]]["parent"] - else: - self.job.logger.warning(f"Parent to {location['name']} can't be found so will be skipped.") - continue - if bldg_name in self.job.location_map and "name" in self.job.location_map[bldg_name]: - area_name = self.job.location_map[bldg_name]["parent"] - bldg_name = self.job.location_map[bldg_name]["name"] - floor_name = f"{bldg_name} - {location['name']}" - try: - parent = self.get(self.building, {"name": bldg_name, "area": area_name}) - new_floor, loaded = self.get_or_instantiate( - self.floor, - ids={"name": floor_name, "building": bldg_name}, - attrs={"tenant": self.tenant.name if self.tenant else None, "uuid": None}, - ) - if loaded: - parent.add_child(new_floor) - except ObjectNotFound as err: - self.job.logger.warning( - f"Unable to find {self.job.building_loctype.name} {bldg_name} for {self.job.floor_loctype.name} {floor_name}. {err}" - ) - - def parse_and_sort_locations(self, locations: List[dict]): - """Separate locations into areas, buildings, and floors for processing. Also sort by siteHierarchy. - - Args: - locations (List[dict]): List of Locations (Sites) from DNAC to be separated. - - Returns: - tuple (List[dict], List[dict], List[dict]): Tuple containing lists of areas, buildings, and floors in DNAC to be processed. - """ - areas, buildings, floors = [], [], [] - for location in locations: - self.dnac_location_map[location["id"]] = {"name": location["name"], "loc_type": "area"} - for location in locations: - for info in location["additionalInfo"]: - if info["attributes"].get("type") == "building": - buildings.append(location) - self.dnac_location_map[location["id"]]["loc_type"] = "building" - break - if info["attributes"].get("type") == "floor": - floors.append(location) - self.dnac_location_map[location["id"]]["loc_type"] = "floor" - break - else: - areas.append(location) - if location.get("parentId") and location["parentId"] in self.dnac_location_map: - self.dnac_location_map[location["id"]]["parent"] = self.dnac_location_map[location["parentId"]]["name"] - else: - self.dnac_location_map[location["id"]]["parent"] = None - # sort areas by length of siteHierarchy so that parent areas loaded before child areas. - areas = sorted(areas, key=lambda x: len(x["siteHierarchy"].split("/"))) - return areas, buildings, floors - - def build_dnac_location_map(self, locations: List[dict]): - """Build out the initial DNAC location map for Location ID to name and type. - - Args: - locations (List[dict]): List of Locations (Sites) from DNAC. - - Returns: - dict: Dictionary of Locations mapped with ID to their name and location type. + floor_name (str): Name of Floor location to be loaded. + bldg_name (str): Name of Building location that Floor is a part of. + area_name (str): Name of Area that Building location resides in. """ - location_map = {} - for loc in locations: - if not settings.PLUGINS_CONFIG["nautobot_ssot"].get("dna_center_import_global"): - if loc["name"] == "Global": - continue - location_map[loc["id"]] = {"name": loc["name"], "parent": None, "loc_type": "area"} - return location_map + if bldg_name not in floor_name: + floor_name = f"{bldg_name} - {floor_name}" + if self.job.debug: + self.job.logger.info(f"Loading floor {floor_name} in {area_name} area.") + try: + parent = self.get(self.building, {"name": bldg_name, "area": area_name}) + new_floor, loaded = self.get_or_instantiate( + self.floor, + ids={"name": floor_name, "building": bldg_name}, + attrs={"tenant": self.tenant.name if self.tenant else None, "uuid": None}, + ) + if loaded: + parent.add_child(new_floor) + except ObjectNotFound as err: + self.job.logger.warning( + f"Unable to find {self.job.building_loctype.name} {bldg_name} for {self.job.floor_loctype.name} {floor_name}. {err}" + ) def load_devices(self): """Load Device data from DNA Center info DiffSync models.""" @@ -298,7 +312,6 @@ def load_devices(self): or (dev.get("errorDescription") and "Meraki" in dev["errorDescription"]) ): continue - platform = "unknown" dev_role = "Unknown" vendor = "Cisco" if not dev.get("hostname"): @@ -309,19 +322,8 @@ def load_devices(self): } self.failed_import_devices.append(dev) continue - if self.job.hostname_map: - dev_role = parse_hostname_for_role( - hostname_map=self.job.hostname_map, device_hostname=dev["hostname"], default_role="Unknown" - ) - if dev_role == "Unknown": - dev_role = dev["role"] - if dev["softwareType"] in DNA_CENTER_LIB_MAPPER: - platform = DNA_CENTER_LIB_MAPPER[dev["softwareType"]] - else: - if not dev.get("softwareType") and dev.get("type") and ("3800" in dev["type"] or "9130" in dev["type"]): - platform = "cisco_ios" - if not dev.get("softwareType") and dev.get("family") and "Meraki" in dev["family"]: - platform = "cisco_meraki" + dev_role = self.get_device_role(dev) + platform = self.get_device_platform(dev) if platform == "unknown": self.job.logger.warning(f"Device {dev['hostname']} is missing Platform so will be skipped.") dev["field_validation"] = { @@ -351,6 +353,7 @@ def load_devices(self): } self.failed_import_devices.append(dev) continue + self.load_device_location_tree(dev_details, loc_data) try: if self.job.debug: self.job.logger.info( @@ -370,6 +373,12 @@ def load_devices(self): self.failed_import_devices.append(dev) continue except ObjectNotFound: + if loc_data.get("floor") and loc_data["building"] not in loc_data["floor"]: + floor_name = f"{loc_data['building']} - {loc_data['floor']}" + elif loc_data.get("floor"): + floor_name = loc_data["floor"] + else: + floor_name = None new_dev = self.device( name=dev["hostname"], status="Active" if dev.get("reachabilityStatus") != "Unreachable" else "Offline", @@ -377,7 +386,7 @@ def load_devices(self): vendor=vendor, model=self.conn.get_model_name(models=dev["platformId"]) if dev.get("platformId") else "Unknown", site=loc_data["building"], - floor=f"{loc_data['building']} - {loc_data['floor']}" if loc_data.get("floor") else None, + floor=floor_name, serial=dev["serialNumber"] if dev.get("serialNumber") else "", version=dev.get("softwareVersion"), platform=platform, @@ -398,6 +407,83 @@ def load_devices(self): } self.failed_import_devices.append(dev) + def load_device_location_tree(self, dev_details: dict, loc_data: dict): + """Load Device locations into DiffSync models for Floor, Building, and Areas. + + Args: + dev_details (dict): Dictionary of Device information. + loc_data (dict): Location data for the Device. + """ + floor_id = "" + location_ids = dev_details["siteHierarchyGraphId"].lstrip("/").rstrip("/").split("/") + if loc_data.get("floor"): + floor_id = location_ids.pop() + building_id = location_ids.pop() + areas = location_ids + + for area_id in reversed(areas): + if self.dnac_location_map.get(area_id): + area_name = self.dnac_location_map[area_id]["name"] + area_parent = self.dnac_location_map[area_id]["parent"] + if self.job.debug: + self.job.logger.debug(f"Loading area {area_name} in {area_parent}.") + self.load_area(area=area_name, area_parent=area_parent) + if self.job.debug: + self.job.logger.debug( + f"Loading building {self.dnac_location_map[building_id]['name']} in {self.dnac_location_map[building_id]['parent']} which exists in {self.dnac_location_map[building_id]['parent_of_parent']} area parent." + ) + self.load_building( + building=self.building_map[building_id], + area_name=self.dnac_location_map[building_id]["parent"], + area_parent_name=self.dnac_location_map[building_id]["parent_of_parent"], + ) + if loc_data.get("floor"): + if self.job.debug: + self.job.logger.debug( + f"Loading floor {self.dnac_location_map[floor_id]['name']} in {self.dnac_location_map[floor_id]['parent']} which exists in {self.dnac_location_map[building_id]['parent']} area." + ) + self.load_floor( + floor_name=self.dnac_location_map[floor_id]["name"], + bldg_name=self.dnac_location_map[floor_id]["parent"], + area_name=self.dnac_location_map[building_id]["parent"], + ) + + def get_device_role(self, dev): + """Get Device Role from Job Hostname map or DNA Center 'role'. + + Args: + dev (dict): Dictionary of information about Device from DNA Center. + + Returns: + str: Device role that has been determined from Hostname map or DNA Center information. + """ + if self.job.hostname_map: + dev_role = parse_hostname_for_role( + hostname_map=self.job.hostname_map, device_hostname=dev["hostname"], default_role="Unknown" + ) + else: + dev_role = dev["role"] + return dev_role + + def get_device_platform(self, dev): + """Get Device Platform from Job information. + + Args: + dev (dict): Dictionary of information about Device from DNA Center. + + Returns: + str: Device platform that has been determined from DNA Center information. + """ + platform = "unknown" + if dev["softwareType"] in DNA_CENTER_LIB_MAPPER: + platform = DNA_CENTER_LIB_MAPPER[dev["softwareType"]] + else: + if not dev.get("softwareType") and dev.get("type") and ("3800" in dev["type"] or "9130" in dev["type"]): + platform = "cisco_ios" + if not dev.get("softwareType") and dev.get("family") and "Meraki" in dev["family"]: + platform = "cisco_meraki" + return platform + def load_ports(self, device_id: str, dev: DnaCenterDevice, mgmt_addr: str = ""): """Load port info from DNAC into Port DiffSyncModel. diff --git a/nautobot_ssot/integrations/dna_center/diffsync/adapters/nautobot.py b/nautobot_ssot/integrations/dna_center/diffsync/adapters/nautobot.py index d0af61b01..a90139d7a 100644 --- a/nautobot_ssot/integrations/dna_center/diffsync/adapters/nautobot.py +++ b/nautobot_ssot/integrations/dna_center/diffsync/adapters/nautobot.py @@ -20,6 +20,7 @@ from nautobot.dcim.models import Interface as OrmInterface from nautobot.dcim.models import Location as OrmLocation from nautobot.dcim.models import LocationType as OrmLocationType +from nautobot.dcim.models import Platform from nautobot.extras.models import Relationship as OrmRelationship from nautobot.extras.models import RelationshipAssociation as OrmRelationshipAssociation from nautobot.extras.models import Status as OrmStatus @@ -63,6 +64,7 @@ class NautobotAdapter(Adapter): site_map = {} floor_map = {} device_map = {} + platform_map = {} port_map = {} namespace_map = {} prefix_map = {} @@ -398,6 +400,9 @@ def load(self): self.status_map = {status.name: status.id for status in OrmStatus.objects.only("id", "name")} self.tenant_map = {tenant.name: tenant.id for tenant in OrmTenant.objects.only("id", "name")} self.namespace_map = {ns.name: ns.id for ns in Namespace.objects.only("id", "name")} + self.platform_map = { + platform.network_driver: platform.id for platform in Platform.objects.only("id", "network_driver") + } self.load_areas() self.load_buildings() diff --git a/nautobot_ssot/integrations/dna_center/diffsync/models/nautobot.py b/nautobot_ssot/integrations/dna_center/diffsync/models/nautobot.py index 267f1f0ea..35cb6a760 100644 --- a/nautobot_ssot/integrations/dna_center/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/dna_center/diffsync/models/nautobot.py @@ -207,6 +207,7 @@ def create(cls, adapter, ids, attrs): device_role.validated_save() device_type, _ = DeviceType.objects.get_or_create(model=attrs["model"], manufacturer=manufacturer) platform = verify_platform(platform_name=attrs["platform"], manu=manufacturer.id) + adapter.platform_map[attrs["platform"]] = platform.id new_device = Device( name=ids["name"], status_id=adapter.status_map[attrs["status"]], @@ -269,7 +270,9 @@ def update(self, attrs): if "platform" in attrs: vendor = attrs["vendor"] if attrs.get("vendor") else self.vendor manufacturer = Manufacturer.objects.get(name=vendor) - device.platform = verify_platform(platform_name=attrs["platform"], manu=manufacturer.id) + platform = verify_platform(platform_name=attrs["platform"], manu=manufacturer.id) + device.platform = platform + self.adapter.platform_map[attrs["platform"]] = platform.id if "tenant" in attrs: if attrs.get("tenant"): device.tenant_id = self.adapter.tenant_map[attrs["tenant"]] @@ -291,7 +294,7 @@ def update(self, attrs): platform = self.platform device.software_version = SoftwareVersion.objects.get_or_create( version=attrs["version"], - platform__name=platform, + platform_id=self.adapter.platform_map[platform], defaults={"status_id": self.adapter.status_map["Active"]}, )[0] device.custom_field_data.update({"system_of_record": "DNA Center"}) diff --git a/nautobot_ssot/integrations/dna_center/utils/nautobot.py b/nautobot_ssot/integrations/dna_center/utils/nautobot.py index d140179cf..197a8b545 100644 --- a/nautobot_ssot/integrations/dna_center/utils/nautobot.py +++ b/nautobot_ssot/integrations/dna_center/utils/nautobot.py @@ -54,13 +54,12 @@ def add_software_lcm(adapter, platform: str, version: str): Returns: UUID: UUID of the OS Version that is being found or created. """ - platform_obj = Platform.objects.get(network_driver=platform) try: - os_ver = SoftwareLCM.objects.get(device_platform=platform_obj, version=version).id + os_ver = SoftwareLCM.objects.get(device_platform_id=adapter.platform_map[platform], version=version).id except SoftwareLCM.DoesNotExist: adapter.job.logger.info(f"Creating Version {version} for {platform}.") os_ver = SoftwareLCM( - device_platform=platform_obj, + device_platform_id=adapter.platform_map[platform], version=version, ) os_ver.validated_save() diff --git a/nautobot_ssot/integrations/librenms/constants.py b/nautobot_ssot/integrations/librenms/constants.py new file mode 100644 index 000000000..595ac0713 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/constants.py @@ -0,0 +1,378 @@ +"""Constants for LibreNMS SSoT.""" + +from django.conf import settings + +# Import config vars from nautobot_config.py +PLUGIN_CFG = settings.PLUGINS_CONFIG["nautobot_ssot"] + +librenms_status_map = { + 0: "Offline", + 1: "Active", + True: "Active", + False: "Offline", +} + +os_manufacturer_map = { + # Other types + "ping": "Generic", + "hpe-ilo": "HP", + "proxmox": "Proxmox", + # Types from LibreNMS/OS php files + "aen": "Accedian OS", + "airos": "Ubiquiti", + "airosaf": "Ubiquiti", + "airosaf60": "Ubiquiti", + "airosafltu": "Ubiquiti", + "airport": "Apple", + "aix": "IBM", + "alcoma_almp": "Alcoma", + "alfo80hd": "Siae Microelettronica", + "allied": "Allied Telesis", + "allworxvoip": "Allworx", + "aos": "Alcatel-Lucent", + "apc": "APC", + "apexlynx": "Apex", + "apexplus": "Apex", + "aprisa": "4RF", + "apsoluteos": "Radware", + "arbos": "Aruba Networks", + "areca": "Areca", + "arrisc4": "Arris", + "arriscm": "Arris", + "arrisdsr4410md": "Arris", + "arubainstant": "Aruba Networks", + "arubaos": "Aruba Networks", + "arubaoscx": "Aruba Networks", + "asa": "Cisco", + "asuswrtmerlin": "Asus", + "asyncos": "Cisco", + "aviatwtm": "Aviat Networks", + "avocent": "Avocent", + "awplus": "Allied Telesis", + "axos": "Calix", + "baicellsod04": "Baicells", + "barracudangfirewall": "Barracuda", + "bats": "BAT", + "beagleboard": "BeagleBoard", + "boss": "Beijer Electronics", + "brother": "Brother", + "ceraos": "Ceragon", + "cienarls": "Ciena", + "cienasds": "Ciena", + "ciscosb": "Cisco", + "ciscowlc": "Cisco", + "cnpilote": "Cambium Networks", + "comware": "HPE", + "coriant": "Coriant", + "cumulus": "Cumulus Networks", + "danthermos": "Dantherm", + "ddwrt": "DD-WRT", + "deliberant": "Deliberant", + "delllaser": "Dell", + "dhcpatriot": "DHCPatriot", + "dlink": "D-Link", + "dlinkap": "D-Link", + "dnos": "Dell EMC", + "edgecos": "Edgecore", + "edgeos": "Ubiquiti", + "edgeosolt": "Ubiquiti", + "edgeswitch": "Ubiquiti", + "ekinops": "Ekinops", + "eltexmes23xx": "Eltex", + "eltexmes24xx": "Eltex", + "engenius": "EnGenius", + "enterasys": "Enterasys", + "epmp": "Cambium Networks", + "ericsson6600": "Ericsson", + "ericssonml": "Ericsson", + "ericssontn": "Ericsson", + "eurostor": "Eurostor", + "ewc": "Extreme Networks", + "exa": "Exa Networks", + "extendair": "ExtendAir", + "extremeware": "Extreme Networks", + "f5": "F5 Networks", + "fabos": "Brocade", + "fortiadc": "Fortinet", + "fortiap": "Fortinet", + "fortiextender": "Fortinet", + "fortigate": "Fortinet", + "fortios": "Fortinet", + "fortiwlc": "Fortinet", + "fscentec": "FS", + "fsgbn": "FS", + "fsswitch": "FS", + "ftos": "Dell EMC", + "gaia": "Check Point", + "generic": "Generic", + "gepulsar": "Ge", + "harmonyenhanced": "Harmony", + "helios": "Helios", + "himoinstags": "Himoinsa", + "hiveoswireless": "HiveOS", + "horizoncompact": "Dragonwave", + "horizoncompactplus": "Dragonwave", + "horizonduo": "Dragonwave", + "hpmsm": "HP", + "hpvc": "HP", + "icros": "Advantech", + "ifotec": "Ifotec", + "infineragroove": "Infinera", + "infinity": "Infinity", + "ios": "Cisco", + "iosxe": "Cisco", + "iosxr": "Cisco", + "ipolis": "Samsung", + "ird": "IRD", + "ironware": "Brocade", + "jetdirect": "HP", + "junos": "Juniper", + "junose": "Juniper", + "lcos": "Lancom", + "lcoslx": "Lancom", + "lcossx": "Lancom", + "linux": "Linux", + "mimosa": "Mimosa Networks", + "mni": "MNI", + "moxaetherdevice": "Moxa", + "mrvod": "MRV", + "netscaler": "Citrix", + "netsure": "NetSure", + "nios": "Infoblox", + "nitro": "Citrix", + "nsbsd": "BSD", + "ocnos": "OcNOS", + "okilan": "Oki", + "openbsd": "BSD", + "openwrt": "OpenWrt", + "packetlight": "PacketLight", + "panos": "Palo Alto", + "pbn": "PBN", + "pbncp": "PBN", + "pepwave": "Peplink", + "pfsense": "PfSense", + "pmp": "Cambium Networks", + "poweralert": "APC", + "powerconnect": "Dell EMC", + "procurve": "HPE", + "protelevisiont1": "ProTelevision", + "ptp250": "Cambium Networks", + "ptp500": "Cambium Networks", + "ptp600": "Cambium Networks", + "ptp650": "Cambium Networks", + "ptp670": "Cambium Networks", + "ptp800": "Cambium Networks", + "pulse": "PulseSecure", + "qnap": "QNAP", + "quanta": "Quanta", + "quantastor": "QuantaStor", + "radlan": "Radlan", + "radwin": "Radwin", + "ray": "Racom", + "ray3": "Racom", + "riverbed": "Riverbed", + "routeros": "Mikrotik", + "ruckuswireless": "Ruckus", + "ruckuswirelesshotzone": "Ruckus", + "ruckuswirelesssz": "Ruckus", + "ruckuswirelessunleashed": "Ruckus", + "rutos2xx": "Teltonika", + "rutosrutx": "Teltonika", + "saf": "Saf Tehnika", + "safcfm": "Saf Tehnika", + "safintegrab": "Saf Tehnika", + "safintegrae": "Saf Tehnika", + "safintegraw": "Saf Tehnika", + "safintegrax": "Saf Tehnika", + "scalance": "Siemens", + "schleifenbauer": "Schleifenbauer", + "screenos": "Juniper", + "secureplatform": "Check Point", + "serveriron": "Brocade", + "sgos": "Symantec", + "siklu": "Siklu", + "siteboss": "SiteBoss", + "siteboss550": "SiteBoss", + "smos": "Microchip", + "smartax": "Huawei", + "smartaxmdu": "Huawei", + "socomecups": "Socomec", + "sonicwall": "SonicWall", + "speedtouch": "Thomson", + "stellar": "Stellar", + "supermicrobmc": "Supermicro", + "svos": "Supermicro", + "symbol": "Symbol", + "tachyon": "Tachyon Networks", + "taitinfra93": "Tait", + "teldat": "Teldat", + "terra": "Terra", + "threecom": "3Com", + "timos": "Nokia", + "topvision": "TopVision", + "ucos": "UC-OS", + "unifi": "Ubiquiti", + "valere": "Valere", + "viptela": "Cisco", + "vmwareesxi": "VMware", + "vrp": "Huawei", + "windows": "Microsoft", + "xerox": "Xerox", + "xirrusaos": "Xirrus", + "xos": "Extreme Networks", + "zebra": "Zebra", + "zxdsl": "ZTE", + "zynos": "Zyxel", + "zywall": "Zyxel", + "zyxelnwa": "Zyxel", + "zyxelwlc": "Zyxel", +} + +manufacturer_os_map = { + # Other Types + "Proxmox": ["proxmox"], + "Generic": ["generic", "ping"], + # Types imported from LibreNMS/OS php files + "4RF": ["aprisa"], + "3Com": ["threecom"], + "Accedian OS": ["aen"], + "Advantech": ["icros"], + "Alcatel-Lucent": ["aos"], + "Alcoma": ["alcoma_almp"], + "Allied Telesis": ["allied", "awplus"], + "Allworx": ["allworxvoip"], + "APC": ["apc", "poweralert"], + "Apple": ["airport"], + "Aruba Networks": ["arbos", "arubainstant", "arubaos", "arubaoscx"], + "Areca": ["areca"], + "Arris": ["arrisc4", "arriscm", "arrisdsr4410md"], + "BAT": ["bats"], + "Baicells": ["baicellsod04"], + "Barracuda": ["barracudangfirewall"], + "BeagleBoard": ["beagleboard"], + "Beijer Electronics": ["boss"], + "Brother": ["brother"], + "Brocade": ["fabos", "ironware", "serveriron"], + "BSD": ["nsbsd", "openbsd"], + "Cambium Networks": [ + "epmp", + "pmp", + "ptp250", + "ptp500", + "ptp600", + "ptp650", + "ptp670", + "ptp800", + ], + "Calix": ["axos"], + "Ceragon": ["ceraos"], + "Check Point": ["gaia", "secureplatform"], + "Ciena": ["cienarls", "cienasds"], + "Citrix": ["netscaler", "nitro"], + "Cisco": [ + "asa", + "asyncos", + "ciscosb", + "ciscowlc", + "fortigate", + "ios", + "iosxe", + "iosxr", + "viptela", + ], + "Cyberpower": ["cyberpower"], + "D-Link": ["dlink", "dlinkap"], + "Dantherm": ["danthermos"], + "DD-WRT": ["ddwrt"], + "Dell": ["delllaser"], + "Dell EMC": ["dnos", "powerconnect", "ftos"], + "Dragonwave": ["horizoncompact", "horizoncompactplus", "horizonduo"], + "Eltex": ["eltexmes23xx", "eltexmes24xx"], + "EnGenius": ["engenius"], + "Enterasys": ["enterasys"], + "Ericsson": ["ericsson6600", "ericssonml", "ericssontn"], + "Ekinops": ["ekinops"], + "Eurostor": ["eurostor"], + "Extreme Networks": ["extremeware", "ewc", "xos"], + "Exa Networks": ["exa"], + "FS": ["fscentec", "fsgbn", "fsswitch"], + "F5 Networks": ["f5"], + "Fortinet": [ + "fortiadc", + "fortiap", + "fortiextender", + "fortigate", + "fortios", + "fortiwlc", + ], + "Ge": ["gepulsar"], + "Harmony": ["harmonyenhanced"], + "Helios": ["helios"], + "Himoinsa": ["himoinstags"], + "HPE": ["comware", "procurve"], + "Huawei": ["smartax", "smartaxmdu", "vrp"], + "IBM": ["aix"], + "Infinera": ["infineragroove"], + "Infinity": ["infinity"], + "Juniper": ["junos", "junose", "screenos"], + "Lancom": ["lcos", "lcoslx", "lcossx"], + "Linux": ["linux"], + "Mikrotik": ["routeros"], + "Mimosa Networks": ["mimosa"], + "Microsoft": ["windows"], + "MRV": ["mrvod"], + "NetSure": ["netsure"], + "Nokia": ["timos"], + "OpenWrt": ["openwrt"], + "PacketLight": ["packetlight"], + "Palo Alto": ["panos"], + "Peplink": ["pepwave"], + "PfSense": ["pfsense"], + "PulseSecure": ["pulse"], + "QNAP": ["qnap"], + "Quanta": ["quanta"], + "QuantaStor": ["quantastor"], + "Racom": ["ray", "ray3"], + "Radlan": ["radlan"], + "Radwin": ["radwin"], + "Ruckus": [ + "ruckuswireless", + "ruckuswirelesshotzone", + "ruckuswirelesssz", + "ruckuswirelessunleashed", + ], + "Saf Tehnika": [ + "saf", + "safcfm", + "safintegrab", + "safintegrae", + "safintegraw", + "safintegrax", + ], + "Samsung": ["ipolis"], + "Schleifenbauer": ["schleifenbauer"], + "Siemens": ["scalance"], + "SiteBoss": ["siteboss", "siteboss550"], + "Supermicro": ["supermicrobmc", "svos"], + "Symbol": ["symbol"], + "Tachyon Networks": ["tachyon"], + "Teltonika": ["rutos2xx", "rutosrutx"], + "Terra": ["terra"], + "Thomson": ["speedtouch"], + "Ubiquiti": [ + "airos", + "airosaf", + "airosaf60", + "airosafltu", + "edgeos", + "edgeosolt", + "edgeswitch", + "unifi", + ], + "VMware": ["vmwareesxi"], + "Xirrus": ["xirrusaos"], + "Xerox": ["xerox"], + "Zebra": ["zebra"], + "ZTE": ["zxdsl"], + "Zyxel": ["zynos", "zywall", "zyxelnwa", "zyxelwlc"], +} diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/__init__.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/__init__.py new file mode 100644 index 000000000..fd930e780 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/__init__.py @@ -0,0 +1 @@ +"""Adapter classes for loading DiffSyncModels with data from LibreNMS or Nautobot.""" diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py new file mode 100644 index 000000000..ff8edca88 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py @@ -0,0 +1,145 @@ +"""Nautobot Ssot Librenms Adapter for LibreNMS SSoT app.""" + +import os + +from diffsync import DiffSync +from diffsync.exceptions import ObjectNotFound +from django.contrib.contenttypes.models import ContentType +from nautobot.dcim.models import Location, LocationType +from nautobot.extras.models import Status + +from nautobot_ssot.integrations.librenms.constants import ( + librenms_status_map, + os_manufacturer_map, +) +from nautobot_ssot.integrations.librenms.diffsync.models.librenms import ( + LibrenmsDevice, + LibrenmsLocation, +) +from nautobot_ssot.integrations.librenms.utils import ( + normalize_gps_coordinates, +) +from nautobot_ssot.integrations.librenms.utils.librenms import LibreNMSApi + + +class LibrenmsAdapter(DiffSync): + """DiffSync adapter for LibreNMS.""" + + location = LibrenmsLocation + device = LibrenmsDevice + + top_level = ["location", "device"] + + def __init__(self, *args, job=None, sync=None, librenms_api: LibreNMSApi, **kwargs): + """Initialize LibreNMS. + + Args: + job (object, optional): LibreNMS job. Defaults to None. + sync (object, optional): LibreNMS DiffSync. Defaults to None. + client (object): LibreNMS API client connection object. + """ + super().__init__(*args, **kwargs) + self.job = job + self.sync = sync + self.lnms_api = librenms_api + + def load_location(self, location: dict): + """Load Location objects from LibreNMS into DiffSync models.""" + if self.job.debug: + self.job.logger.debug(f'Loading LibreNMS Location {location["location"]}') + + try: + self.get(self.location, location["location"]) + except ObjectNotFound: + _latitude = None + _longitude = None + if location["lat"]: + _latitude = normalize_gps_coordinates(location["lat"]) + if location["lng"]: + _longitude = normalize_gps_coordinates(location["lng"]) + new_location = self.location( + name=location["location"], + status="Active", + location_type="Site", + latitude=_latitude, + longitude=_longitude, + system_of_record=os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS"), + ) + self.add(new_location) + + def load_device(self, device: dict): + """Load Device objects from LibreNMS into DiffSync models.""" + if self.job.debug: + self.job.logger.debug(f'Loading LibreNMS Device {device["sysName"]}') + + if device["os"] != "ping": + try: + self.get(self.device, device["sysName"]) + except ObjectNotFound: + if device["disabled"] == 1: + _status = "Offline" + else: + _status = librenms_status_map[device["status"]] + new_device = self.device( + name=device[self.hostname_field], + device_id=device["device_id"], + location=(device["location"] if device["location"] is not None else "Unknown"), + role=device["type"] if device["type"] is not None else None, + serial_no=device["serial"] if device["serial"] is not None else "", + status=_status, + manufacturer=( + os_manufacturer_map.get(device["os"]) + if os_manufacturer_map.get(device["os"]) is not None + else "Unknown" + ), + device_type=(device["hardware"] if device["hardware"] is not None else "Unknown"), + platform=device["os"] if device["os"] is not None else "Unknown", + os_version=(device["version"] if device["version"] is not None else "Unknown"), + system_of_record=os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS"), + ) + self.add(new_device) + else: + self.job.logger.info(f'Device {device[self.hostname_field]} is "ping-only". Skipping.') + + def load(self): + """Load data from LibreNMS into DiffSync models.""" + self.hostname_field = ( + os.getenv("NAUTOBOT_SSOT_LIBRENMS_HOSTNAME_FIELD", "sysName") + if self.job.hostname_field == "env_var" + else self.job.hostname_field or "sysName" + ) + + load_source = self.job.load_type + + if load_source != "file": + all_devices = self.lnms_api.get_librenms_devices() + else: + all_devices = self.lnms_api.get_librenms_devices_from_file() + + self.job.logger.info(f'Loading {all_devices["count"]} Devices from LibreNMS.') + + for _device in all_devices["devices"]: + self.load_device(device=_device) + + if self.job.sync_locations: + _site, _created = LocationType.objects.get_or_create(name="Site") + if _created: + _site.content_types.add(ContentType.objects.get(app_label="dcim", model="device")) + _status = Status.objects.get(name="Active") + Location.objects.get_or_create( + name="Unknown", + location_type=_site, + status=_status, + ) + + if load_source != "file": + all_locations = self.lnms_api.get_librenms_locations() + else: + all_locations = self.lnms_api.get_librenms_locations_from_file() + + self.job.logger.info(f'Loading {all_locations["count"]} Locations from LibreNMS.') + + for _location in all_locations["locations"]: + self.load_location(location=_location) + else: + self.job.logger.info("Location Sync Disabled. Skipping loading locations.") diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py new file mode 100644 index 000000000..07820283a --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py @@ -0,0 +1,114 @@ +"""Nautobot Adapter for LibreNMS SSoT app.""" + +from typing import Optional + +from diffsync import DiffSync +from diffsync.enum import DiffSyncModelFlags +from diffsync.exceptions import ObjectNotFound +from nautobot.dcim.models import Device as OrmDevice +from nautobot.dcim.models import Location as OrmLocation +from nautobot.tenancy.models import Tenant + +from nautobot_ssot.integrations.librenms.diffsync.models.nautobot import ( + NautobotDevice, + NautobotLocation, +) +from nautobot_ssot.integrations.librenms.utils import check_sor_field, get_sor_field_nautobot_object + + +class NautobotAdapter(DiffSync): + """DiffSync adapter for Nautobot.""" + + location = NautobotLocation + device = NautobotDevice + + top_level = ["location", "device"] + + def __init__(self, *args, job=None, sync=None, tenant: Optional[Tenant] = None, **kwargs): + """Initialize Nautobot. + + Args: + job (object, optional): Nautobot job. Defaults to None. + sync (object, optional): Nautobot DiffSync. Defaults to None. + """ + super().__init__(*args, **kwargs) + self.tenant = tenant + self.job = job + self.sync = sync + + def load_location(self): + """Load Location objects from Nautobot into DiffSync Models.""" + if self.tenant: + locations = OrmLocation.objects.filter(tenant=self.tenant) + else: + locations = OrmLocation.objects.all() + for nb_location in locations: + self.job.logger.debug(f"Loading Nautobot Location {nb_location}") + try: + self.get(self.location, nb_location.name) + except ObjectNotFound: + _parent = None + if nb_location.parent is not None: + _parent = nb_location.parent.name + new_location = NautobotLocation( + name=nb_location.name, + location_type=nb_location.location_type.name, + parent=_parent, + latitude=nb_location.latitude, + longitude=nb_location.longitude, + status=nb_location.status.name, + system_of_record=get_sor_field_nautobot_object(nb_location), + uuid=nb_location.id, + ) + if not check_sor_field(nb_location): + new_location.model_flags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + self.add(new_location) + + def load_device(self): + """Load Device objects from Nautobot into DiffSync models.""" + if self.tenant: + devices = OrmDevice.objects.filter(tenant=self.tenant) + else: + devices = OrmDevice.objects.all() + for nb_device in devices: + self.job.logger.debug(f"Loading Nautobot Device {nb_device}") + try: + self.get(self.device, nb_device.name) + except ObjectNotFound: + try: + _software_version = nb_device.software_version.version + except AttributeError: + _software_version = None + _device_id = None + if nb_device.custom_field_data.get("librenms_device_id"): + _device_id = nb_device.custom_field_data.get("librenms_device_id") + new_device = NautobotDevice( + name=nb_device.name, + device_id=_device_id, + location=nb_device.location.name, + status=nb_device.status.name, + device_type=nb_device.device_type.model, + role=nb_device.role.name, + manufacturer=nb_device.device_type.manufacturer.name, + platform=nb_device.platform.name, + os_version=_software_version, + serial_no=nb_device.serial, + system_of_record=get_sor_field_nautobot_object(nb_device), + uuid=nb_device.id, + ) + if not check_sor_field(nb_device): + new_device.model_flags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + self.add(new_device) + + def load(self): + """Load data from Nautobot into DiffSync models.""" + if self.job.sync_locations: + if self.job.debug: + self.job.logger.debug("Loading Nautobot Locations") + self.load_location() + + if self.job.debug: + self.job.logger.debug("Loading Nautobot Devices") + self.load_device() diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/__init__.py b/nautobot_ssot/integrations/librenms/diffsync/models/__init__.py new file mode 100644 index 000000000..00d58e1d6 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/models/__init__.py @@ -0,0 +1 @@ +"""DiffSync models and adapters for the LibreNMS SSoT app.""" diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/base.py b/nautobot_ssot/integrations/librenms/diffsync/models/base.py new file mode 100644 index 000000000..18ff3d5dc --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/models/base.py @@ -0,0 +1,211 @@ +"""DiffSyncModel subclasses for Nautobot-to-LibreNMS data sync.""" + +from typing import List, Optional +from uuid import UUID + +from diffsync import DiffSyncModel + + +class Location(DiffSyncModel): + """DiffSync Model for LibreNMS Location.""" + + _modelname = "location" + _identifiers = ("name",) + _attributes = ( + "status", + "location_type", + "tenant", + "parent", + "latitude", + "longitude", + "system_of_record", + ) + _children = {} + + name: str + status: str + location_type: str + tenant: Optional[str] = None + parent: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + system_of_record: str + + uuid: Optional[UUID] = None + + +class Device(DiffSyncModel): + """DiffSync Model for LibreNMS Device.""" + + _modelname = "device" + _identifiers = ("name",) + _attributes = ( + "device_id", + "location", + "tenant", + "status", + "device_type", + "role", + "manufacturer", + "platform", + "os_version", + "serial_no", + "tags", + "system_of_record", + ) + _children = {"port": "interfaces"} + + name: str + device_id: Optional[int] = None + location: str + tenant: Optional[str] = None + status: str + device_type: str + role: Optional[str] = None + manufacturer: str + platform: Optional[str] = None + os_version: Optional[str] = None + interfaces: Optional[List["Port"]] = [] + serial_no: Optional[str] = None + tags: Optional[List[str]] = None + system_of_record: str + + uuid: Optional[UUID] = None + + +class Port(DiffSyncModel): + """DiffSync Model for LibreNMS Port.""" + + _modelname = "port" + _identifiers = ("device", "name") + _attributes = ( + "status", + "mtu", + "description", + "mac_addr", + "interface_type", + "tags", + "mode", + "vlans", + "system_of_record", + ) + _children = {} + + name: str + device: str + status: Optional[bool] = False + mtu: Optional[int] = None + description: Optional[str] = None + mac_addr: Optional[str] = None + interface_type: Optional[str] = None + tags: Optional[List[str]] = None + mode: Optional[str] = None + vlans: Optional[List[int]] = [] + system_of_record: str + + uuid: Optional[UUID] = None + + +class Prefix(DiffSyncModel): + """DiffSync model for LibreNMS Prefix.""" + + _modelname = "prefix" + _identifiers = ( + "network", + "tenant", + "mask_bits", + "vrf", + ) + _attributes = ("description", "tags", "system_of_record") + _children = {} + network: str + tenant: Optional[str] = None + mask_bits: int + description: Optional[str] = None + vrf: Optional[str] = None + tags: Optional[List[str]] = None + system_of_record: str + + uuid: Optional[UUID] = None + + +class IPAddress(DiffSyncModel): + """DiffSync Model for LibreNMS IPAddress.""" + + _modelname = "ip_address" + _identifiers = ("address", "subnet") + _attributes = ( + "namespace", + "available", + "tenant", + "label", + "device", + "interface", + "primary", + "tags", + "system_of_record", + ) + _children = {} + + address: str + subnet: str + namespace: str + available: bool + tenant: Optional[str] = None + label: Optional[str] = None + device: Optional[str] = None + interface: Optional[str] = None + primary: Optional[bool] = None + tags: Optional[List[str]] = None + system_of_record: str + + uuid: Optional[UUID] = None + + +class Connection(DiffSyncModel): + """DiffSync Model for LibreNMS Connection.""" + + _modelname = "conn" + _identifiers = ( + "src_device", + "src_port", + "src_port_mac", + "dst_device", + "dst_port", + "dst_port_mac", + ) + _attributes = ("src_type", "dst_type", "system_of_record") + _children = {} + + src_device: str + src_port: str + src_type: str + src_port_mac: Optional[str] = None + dst_device: str + dst_port: str + dst_type: str + dst_port_mac: Optional[str] = None + tags: Optional[List[str]] = None + system_of_record: str + + uuid: Optional[UUID] = None + + +class SSoTJob(DiffSyncModel): + """DiffSync model for LibreNMS SSoTJobs.""" + + _modelname = "ssot-job" + _identifiers = ( + "name", + "schedule", + ) + _attributes = () + _children = {} + + name: str + schedule: str + + uuid: Optional[UUID] = None + + +Device.model_rebuild() diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py new file mode 100644 index 000000000..b6d2873f0 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py @@ -0,0 +1,37 @@ +"""Nautobot Ssot Librenms DiffSync models for Nautobot Ssot Librenms SSoT.""" + +from nautobot_ssot.integrations.librenms.diffsync.models.base import Device, Location + + +class LibrenmsLocation(Location): + """LibreNMS implementation of Location DiffSync model.""" + + @classmethod + def create(cls, diffsync, ids, attrs): + """Create Location in LibreNMS from LibrenmsLocation object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update Location in LibreNMS from LibrenmsLocation object.""" + return super().update(attrs) + + def delete(self): + """Delete Location in LibreNMS from LibrenmsLocation object.""" + return self + + +class LibrenmsDevice(Device): + """LibreNMS implementation of Device DiffSync model.""" + + @classmethod + def create(cls, diffsync, ids, attrs): + """Create Device in LibreNMS from LibrenmsDevice object.""" + return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update Device in LibreNMS from LibrenmsDevice object.""" + return super().update(attrs) + + def delete(self): + """Delete Device in LibreNMS from LibrenmsDevice object.""" + return self diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py b/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py new file mode 100644 index 000000000..f7a7c4505 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py @@ -0,0 +1,223 @@ +"""Nautobot DiffSync models for LibreNMS SSoT.""" + +import os +from datetime import datetime + +from django.contrib.contenttypes.models import ContentType +from nautobot.dcim.models import Device as ORMDevice +from nautobot.dcim.models import DeviceType, LocationType +from nautobot.dcim.models import Interface as ORMInterface +from nautobot.dcim.models import Location as ORMLocation +from nautobot.dcim.models import Manufacturer as ORMManufacturer +from nautobot.dcim.models import Platform as ORMPlatform +from nautobot.dcim.models import SoftwareImageFile as ORMSoftwareImageFile +from nautobot.dcim.models import SoftwareVersion as ORMSoftwareVersion +from nautobot.extras.models import Role, Status + +from nautobot_ssot.integrations.librenms.constants import os_manufacturer_map +from nautobot_ssot.integrations.librenms.diffsync.models.base import Device, Location, Port +from nautobot_ssot.integrations.librenms.utils import check_sor_field +from nautobot_ssot.integrations.librenms.utils.nautobot import ( + verify_platform, +) + + +def ensure_role(role_name: str, content_type): + """Safely returns a Role that support given ContentType.""" + content_type = ContentType.objects.get_for_model(content_type) + role, _ = Role.objects.get_or_create(name=role_name) + role.content_types.add(content_type) + return role + + +def ensure_platform(platform_name: str, manufacturer: str): + """Safely returns a Platform that support Devices.""" + try: + _manufacturer, _ = ORMManufacturer.objects.get_or_create(name=manufacturer) + _platform = ORMPlatform.objects.get(name=platform_name, manufacturer=_manufacturer) + return _platform + except ORMPlatform.DoesNotExist: + try: + _platform = ORMPlatform.objects.get(name=platform_name) + return _platform + except ORMPlatform.DoesNotExist: + _platform = verify_platform(platform_name=platform_name, manu=_manufacturer.id) + return _platform + + +def ensure_software_version(platform: ORMPlatform, manufacturer: str, version: str, device_type: DeviceType): + """Safely returns a SoftwareVersion.""" + _image_file_name = f"{version}.bin" + _status = Status.objects.get(name="Active") + _software_version = ORMSoftwareVersion.objects.get_or_create(platform=platform, version=version, status=_status)[0] + _software_image = ORMSoftwareImageFile.objects.get_or_create( + software_version=_software_version, image_file_name=_image_file_name, status=_status + )[0] + _software_image.device_types.add(device_type) + _software_image.validated_save() + return _software_version + + +class NautobotLocation(Location): + """Nautobot implementation of LibreNMS Location model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Location in Nautobot from NautobotLocation object.""" + if adapter.job.debug: + adapter.job.logger.debug(f'Creating Nautobot Location {ids["name"]}') + + new_location = ORMLocation( + name=ids["name"], + latitude=attrs["latitude"], + longitude=attrs["longitude"], + status=Status.objects.get(name=attrs["status"]), + location_type=LocationType.objects.get(name="Site"), + ) + if adapter.tenant: + new_location.tenant = adapter.tenant + new_location.custom_field_data.update( + {"system_of_record": os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS")} + ) + new_location.custom_field_data.update({"last_synced_from_sor": datetime.today().date().isoformat()}) + new_location.validated_save() + return super().create(adapter=adapter, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update Location in Nautobot from NautobotLocation object.""" + if self.adapter.job.debug: + self.adapter.job.logger.debug(f"Updating Nautobot Location {self.name}") + + location = ORMLocation.objects.get(name=self.name) + if "latitude" in attrs: + location.latitude = attrs["latitude"] + if "longitude" in attrs: + location.longitude = attrs["longitude"] + if "status" in attrs: + location.status = Status.objects.get(name=attrs["status"]) + if not check_sor_field(location): + location.custom_field_data.update( + {"system_of_record": os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS")} + ) + location.custom_field_data.update({"last_synced_from_sor": datetime.today().date().isoformat()}) + location.validated_save() + return super().update(attrs) + + def delete(self): + """Delete Location in Nautobot from NautobotLocation object.""" + self.adapter.job.logger.debug(f"Deleting Nautobot Location {self.name}") + location = ORMLocation.objects.get(id=self.id) + super().delete() + location.delete() + return self + + +class NautobotDevice(Device): + """Nautobot implementation of LibreNMS Device model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Device in Nautobot from NautobotDevice object.""" + if adapter.job.debug: + adapter.job.logger.debug(f'Creating Nautobot Device {ids["name"]}') + _manufacturer = ORMManufacturer.objects.get_or_create(name=os_manufacturer_map[attrs["platform"]])[0] + _platform = ensure_platform(platform_name=attrs["platform"], manufacturer=_manufacturer.name) + _device_type = DeviceType.objects.get_or_create(model=attrs["device_type"], manufacturer=_manufacturer)[0] + if adapter.job.debug: + adapter.job.logger.debug(f'Device Location {attrs["location"]}') + new_device = ORMDevice( + name=ids["name"], + device_type=_device_type, + status=Status.objects.get_or_create(name=attrs["status"])[0], + role=ensure_role(role_name=attrs["role"], content_type=ORMDevice), + location=ORMLocation.objects.get( + name=attrs["location"], location_type=LocationType.objects.get(name="Site") + ), + platform=_platform, + serial=attrs["serial_no"], + software_version=ensure_software_version( + platform=_platform, + manufacturer=_manufacturer.name, + version=attrs["os_version"], + device_type=_device_type, + ), + ) + if adapter.tenant: + new_device.tenant = adapter.tenant + new_device.custom_field_data.update({"librenms_device_id": attrs["device_id"]}) + new_device.custom_field_data.update( + {"system_of_record": os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS")} + ) + new_device.custom_field_data.update({"last_synced_from_sor": datetime.today().date().isoformat()}) + new_device.validated_save() + return super().create(adapter=adapter, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update Device in Nautobot from NautobotDevice object.""" + self.adapter.job.logger.debug(f"Updating Nautobot Device {self.name} with {attrs}") + device = ORMDevice.objects.get(id=self.uuid) + if "device_id" in attrs: + device.custom_field_data["librenms_device_id"] = attrs["device_id"] + if "status" in attrs: + device.status = Status.objects.get_or_create(name=attrs["status"])[0] + if "role" in attrs: + device.role = ensure_role(role_name=attrs["role"], content_type=ORMDevice) + if "location" in attrs: + device.location = ORMLocation.objects.get(name=attrs["location"]) + if "serial_no" in attrs: + device.serial = attrs["serial_no"] + if "platform" in attrs: + _manufacturer = ORMManufacturer.objects.get_or_create(name=os_manufacturer_map[attrs["os"]])[0] + device.platform = (ensure_platform(platform_name=attrs["os"], manufacturer=_manufacturer.name),) + if "os_version" in attrs: + _software_version = ensure_software_version( + platform=device.platform, + manufacturer=device.device_type.manufacturer.name, + version=attrs["os_version"], + device_type=device.device_type, + ) + _software_version.devices.add(device) + if not check_sor_field(device): + device.custom_field_data.update( + {"system_of_record": os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS")} + ) + device.custom_field_data.update({"last_synced_from_sor": datetime.today().date().isoformat()}) + device.validated_save() + return super().update(attrs) + + def delete(self): + """Delete Device in Nautobot from NautobotDevice object.""" + self.adapter.job.logger.debug(f"Deleting Nautobot Device {self.name}") + dev = ORMDevice.objects.get(id=self.uuid) + super().delete() + dev.delete() + return self + + +class NautobotPort(Port): + """Nautobot implementation of LibreNMS Port model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Port in Nautobot from NautobotPort object.""" + raise NotImplementedError("NautobotPort create not yet implemented") + adapter.job.logger.debug(f'Creating Nautobot Interface {ids["name"]}') + + return super().create(adapter=adapter, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update Port in Nautobot from NautobotPort object.""" + raise NotImplementedError("NautobotPort update not yet implemented") + self.adapter.job.logger.debug(f"Updating Nautobot Interface {self.name}") + + return super().update(attrs) + + def delete(self): + """Delete Port in Nautobot from NautobotPort object.""" + raise NotImplementedError("NautobotPort delete not yet implemented") + self.adapter.job.logger.debug(f"Deleting Nautobot Interface {self.name}") + + port = ORMInterface.objects.get(id=self.uuid) + super().delete() + port.delete() + return self diff --git a/nautobot_ssot/integrations/librenms/fixtures/get_librenms_devices.json b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_devices.json new file mode 100644 index 000000000..b254b9c88 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_devices.json @@ -0,0 +1,120 @@ +{ + "status": "ok", + "devices": [ + { + "device_id": 7, + "inserted": "2023-04-21 23:01:34", + "hostname": "10.0.10.11", + "sysName": "grch-ap-p2-utpo-303-60", + "display": null, + "ip": "10.0.10.11", + "overwrite_ip": null, + "community": null, + "authlevel": "authPriv", + "authalgo": "SHA", + "cryptoalgo": "AES", + "snmpver": "v3", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.14988.1", + "sysDescr": "RouterOS RBwAPG-60ad", + "sysContact": "comany-x ", + "version": "7.8", + "hardware": "RBwAPG-60ad", + "features": "Level 3", + "location_id": 1, + "os": "routeros", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 22134355, + "agent_uptime": 0, + "last_polled": "2023-12-15 13:30:21", + "last_poll_attempted": null, + "last_polled_timetaken": 14.317140102386, + "last_discovered_timetaken": 27.313, + "last_discovered": "2023-12-15 12:36:47", + "last_ping": "2023-12-15 13:30:08", + "last_ping_timetaken": 0.309, + "purpose": null, + "type": "network", + "serial": "HE508HJDKED", + "icon": "mikrotik.svg", + "poller_group": 1, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 3, + "disable_notify": 0, + "ignore_status": 0, + "dependency_parent_id": "1", + "dependency_parent_hostname": "10.0.255.255", + "location": "City Hall", + "lat": null, + "lng": null + }, + { + "device_id": 1, + "inserted": "2023-03-22 12:19:34", + "hostname": "10.0.255.255", + "sysName": "grch-rt-core", + "display": null, + "ip": "10.0.255.255", + "overwrite_ip": "", + "community": null, + "authlevel": "authPriv", + "authalgo": "SHA", + "cryptoalgo": "AES", + "snmpver": "v3", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.14988.1", + "sysDescr": "RouterOS RB5009UPr+S+", + "sysContact": "comany-x ", + "version": "7.8", + "hardware": "RB5009UPr+S+", + "features": "Level 5", + "location_id": 1, + "os": "routeros", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 22136032, + "agent_uptime": 0, + "last_polled": "2023-12-15 13:30:25", + "last_poll_attempted": null, + "last_polled_timetaken": 19.380449056625, + "last_discovered_timetaken": 41.965, + "last_discovered": "2023-12-15 12:33:46", + "last_ping": "2023-12-15 13:30:08", + "last_ping_timetaken": 0.388, + "purpose": "", + "type": "network", + "serial": "HE108PIEJR2", + "icon": "mikrotik.svg", + "poller_group": 1, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 2, + "disable_notify": 0, + "ignore_status": 0, + "dependency_parent_id": "4", + "dependency_parent_hostname": "localhost", + "location": "GYM", + "lat": null, + "lng": null + } + ], + "count": 2 +} \ No newline at end of file diff --git a/nautobot_ssot/integrations/librenms/fixtures/get_librenms_locations.json b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_locations.json new file mode 100644 index 000000000..9944689db --- /dev/null +++ b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_locations.json @@ -0,0 +1,22 @@ +{ + "status": "ok", + "locations": [ + { + "id": 1, + "location": "City Hall", + "lat": 41.874677429096174, + "lng": -87.62672768379687, + "timestamp": "2023-03-22 15:18:13", + "fixed_coordinates": 1 + }, + { + "id": 2, + "location": "GYM", + "lat": null, + "lng": null, + "timestamp": "2023-03-22 15:18:40", + "fixed_coordinates": 1 + } + ], + "count": 2 +} \ No newline at end of file diff --git a/nautobot_ssot/integrations/librenms/fixtures/get_librenms_port_detail.json b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_port_detail.json new file mode 100644 index 000000000..d52e71159 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_port_detail.json @@ -0,0 +1,75 @@ +{ + "status": "ok", + "port": [ + { + "port_id": 11, + "device_id": 1, + "port_descr_type": null, + "port_descr_descr": null, + "port_descr_circuit": null, + "port_descr_speed": null, + "port_descr_notes": null, + "ifDescr": "ether1", + "ifName": "ether1", + "portName": null, + "ifIndex": 1, + "ifSpeed": 1000000000, + "ifSpeed_prev": 1000000000, + "ifConnectorPresent": null, + "ifOperStatus": "up", + "ifOperStatus_prev": "up", + "ifAdminStatus": "up", + "ifAdminStatus_prev": "up", + "ifDuplex": null, + "ifMtu": 1500, + "ifType": "ethernetCsmacd", + "ifAlias": "ge to internet", + "ifPhysAddress": "48a98a3453eb", + "ifLastChange": 155546602, + "ifVlan": "", + "ifTrunk": null, + "ifVrf": 0, + "ignore": 0, + "disabled": 0, + "deleted": 0, + "pagpOperationMode": null, + "pagpPortState": null, + "pagpPartnerDeviceId": null, + "pagpPartnerLearnMethod": null, + "pagpPartnerIfIndex": null, + "pagpPartnerGroupIfIndex": null, + "pagpPartnerDeviceName": null, + "pagpEthcOperationMode": null, + "pagpDeviceId": null, + "pagpGroupIfIndex": null, + "ifInUcastPkts": 772050550, + "ifInUcastPkts_prev": 772041971, + "ifInUcastPkts_delta": 8579, + "ifInUcastPkts_rate": 29, + "ifOutUcastPkts": 2125079832, + "ifOutUcastPkts_prev": 2125070249, + "ifOutUcastPkts_delta": 9583, + "ifOutUcastPkts_rate": 32, + "ifInErrors": 0, + "ifInErrors_prev": 0, + "ifInErrors_delta": 0, + "ifInErrors_rate": 0, + "ifOutErrors": 0, + "ifOutErrors_prev": 0, + "ifOutErrors_delta": 0, + "ifOutErrors_rate": 0, + "ifInOctets": 297929987779, + "ifInOctets_prev": 297925470017, + "ifInOctets_delta": 4517762, + "ifInOctets_rate": 15059, + "ifOutOctets": 2455908521092, + "ifOutOctets_prev": 2455906739361, + "ifOutOctets_delta": 1781731, + "ifOutOctets_rate": 5939, + "poll_time": 1702668619, + "poll_prev": 1702668319, + "poll_period": 300 + } + ], + "count": 1 +} \ No newline at end of file diff --git a/nautobot_ssot/integrations/librenms/fixtures/get_librenms_ports.json b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_ports.json new file mode 100644 index 000000000..feffdb133 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/fixtures/get_librenms_ports.json @@ -0,0 +1,461 @@ +{ + "status": "ok", + "ports": [ + { + "port_id": 1, + "ifName": "lo" + }, + { + "port_id": 2, + "ifName": "eth0" + }, + { + "port_id": 3, + "ifName": "wlan0" + }, + { + "port_id": 11, + "ifName": "ether1" + }, + { + "port_id": 12, + "ifName": "ether2" + }, + { + "port_id": 13, + "ifName": "ether3" + }, + { + "port_id": 14, + "ifName": "ether4" + }, + { + "port_id": 15, + "ifName": "ether5" + }, + { + "port_id": 16, + "ifName": "ether6" + }, + { + "port_id": 17, + "ifName": "ether7" + }, + { + "port_id": 18, + "ifName": "ether8" + }, + { + "port_id": 19, + "ifName": "lan_bridge" + }, + { + "port_id": 20, + "ifName": "lo0" + }, + { + "port_id": 21, + "ifName": "v2-OOBM" + }, + { + "port_id": 22, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 23, + "ifName": "v20-CORP-WIRELESS" + }, + { + "port_id": 24, + "ifName": "v30-CORP-WIRED" + }, + { + "port_id": 25, + "ifName": "v40-SECURITY-DEVICES" + }, + { + "port_id": 26, + "ifName": "v50-SERVERS" + }, + { + "port_id": 27, + "ifName": "zerotier1" + }, + { + "port_id": 28, + "ifName": "ether1" + }, + { + "port_id": 29, + "ifName": "ether2" + }, + { + "port_id": 30, + "ifName": "ether3" + }, + { + "port_id": 31, + "ifName": "ether4" + }, + { + "port_id": 32, + "ifName": "ether5" + }, + { + "port_id": 33, + "ifName": "ether6" + }, + { + "port_id": 34, + "ifName": "ether7" + }, + { + "port_id": 35, + "ifName": "ether8" + }, + { + "port_id": 36, + "ifName": "lan_bridge" + }, + { + "port_id": 37, + "ifName": "lo0" + }, + { + "port_id": 38, + "ifName": "v2-OOBM" + }, + { + "port_id": 39, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 40, + "ifName": "v20-CORP-WIRELESS" + }, + { + "port_id": 41, + "ifName": "v30-CORP-WIRED" + }, + { + "port_id": 42, + "ifName": "v40-SECURITY-DEVICES" + }, + { + "port_id": 43, + "ifName": "v50-SERVERS" + }, + { + "port_id": 44, + "ifName": "zerotier1" + }, + { + "port_id": 45, + "ifName": "lo" + }, + { + "port_id": 46, + "ifName": "ens18" + }, + { + "port_id": 49, + "ifName": "wg0" + }, + { + "port_id": 51, + "ifName": "docker0" + }, + { + "port_id": 62, + "ifName": "temp-vlan3-camera-setup" + }, + { + "port_id": 63, + "ifName": "wg0" + }, + { + "port_id": 64, + "ifName": "wlan60-1" + }, + { + "port_id": 65, + "ifName": "ether1" + }, + { + "port_id": 66, + "ifName": "wlan60-1" + }, + { + "port_id": 67, + "ifName": "bridge" + }, + { + "port_id": 68, + "ifName": "ether1" + }, + { + "port_id": 69, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 70, + "ifName": "bridge" + }, + { + "port_id": 71, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 72, + "ifName": "wlan60-1" + }, + { + "port_id": 73, + "ifName": "ether1" + }, + { + "port_id": 74, + "ifName": "bridge" + }, + { + "port_id": 75, + "ifName": "wlan60-1" + }, + { + "port_id": 76, + "ifName": "wlan60-station-1" + }, + { + "port_id": 77, + "ifName": "wlan60-1" + }, + { + "port_id": 78, + "ifName": "wlan60-1" + }, + { + "port_id": 79, + "ifName": "ether1" + }, + { + "port_id": 80, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 81, + "ifName": "ether1" + }, + { + "port_id": 82, + "ifName": "bridge" + }, + { + "port_id": 83, + "ifName": "ether1" + }, + { + "port_id": 84, + "ifName": "bridge" + }, + { + "port_id": 85, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 86, + "ifName": "bridge" + }, + { + "port_id": 87, + "ifName": "wlan60-station-1" + }, + { + "port_id": 88, + "ifName": "wlan60-station-1" + }, + { + "port_id": 89, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 90, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 91, + "ifName": "ether1" + }, + { + "port_id": 92, + "ifName": "ether2" + }, + { + "port_id": 93, + "ifName": "ether3" + }, + { + "port_id": 94, + "ifName": "ether4" + }, + { + "port_id": 95, + "ifName": "ether5" + }, + { + "port_id": 96, + "ifName": "lan_bridge" + }, + { + "port_id": 97, + "ifName": "mgmt" + }, + { + "port_id": 98, + "ifName": "ether1" + }, + { + "port_id": 99, + "ifName": "wlan60-1" + }, + { + "port_id": 100, + "ifName": "bridge" + }, + { + "port_id": 101, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 102, + "ifName": "wlan60-station-1" + }, + { + "port_id": 103, + "ifName": "ether1" + }, + { + "port_id": 104, + "ifName": "ether2" + }, + { + "port_id": 105, + "ifName": "ether3" + }, + { + "port_id": 106, + "ifName": "ether4" + }, + { + "port_id": 107, + "ifName": "ether5" + }, + { + "port_id": 109, + "ifName": "lan_bridge" + }, + { + "port_id": 111, + "ifName": "mgmt" + }, + { + "port_id": 121, + "ifName": "remote_support" + }, + { + "port_id": 122, + "ifName": "remote_support" + }, + { + "port_id": 123, + "ifName": "v1000-GUEST-FRONTHALL" + }, + { + "port_id": 124, + "ifName": "v1000-GUEST-FRONTHALL" + }, + { + "port_id": 125, + "ifName": "lo" + }, + { + "port_id": 126, + "ifName": "eth0" + }, + { + "port_id": 127, + "ifName": "wlan0" + }, + { + "port_id": 128, + "ifName": "wg0" + }, + { + "port_id": 129, + "ifName": "v100-EXTRA-MGMT" + }, + { + "port_id": 130, + "ifName": "v200-EXTRA-LANEXTENSION" + }, + { + "port_id": 131, + "ifName": "v100-EXTRA-MGMT" + }, + { + "port_id": 132, + "ifName": "v200-EXTRA-LANEXTENTION" + }, + { + "port_id": 139, + "ifName": "ztbtoqmqf4" + }, + { + "port_id": 140, + "ifName": "ztbtoqmqf4" + }, + { + "port_id": 148, + "ifName": "ether1" + }, + { + "port_id": 149, + "ifName": "wlan60-1" + }, + { + "port_id": 150, + "ifName": "bridge" + }, + { + "port_id": 151, + "ifName": "ether1" + }, + { + "port_id": 152, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 153, + "ifName": "ether2" + }, + { + "port_id": 154, + "ifName": "ether3" + }, + { + "port_id": 155, + "ifName": "ether4" + }, + { + "port_id": 156, + "ifName": "ether5" + }, + { + "port_id": 157, + "ifName": "lan_bridge" + }, + { + "port_id": 158, + "ifName": "mgmt" + } + ] +} \ No newline at end of file diff --git a/nautobot_ssot/integrations/librenms/jobs.py b/nautobot_ssot/integrations/librenms/jobs.py new file mode 100644 index 000000000..189b4f98d --- /dev/null +++ b/nautobot_ssot/integrations/librenms/jobs.py @@ -0,0 +1,215 @@ +"""Jobs for LibreNMS SSoT integration.""" + +import os + +from django.templatetags.static import static +from nautobot.apps.jobs import BooleanVar, ChoiceVar, ObjectVar +from nautobot.core.celery import register_jobs +from nautobot.extras.choices import ( + SecretsGroupAccessTypeChoices, + SecretsGroupSecretTypeChoices, +) +from nautobot.extras.models import ExternalIntegration +from nautobot.tenancy.models import Tenant + +from nautobot_ssot.integrations.librenms.diffsync.adapters import librenms, nautobot +from nautobot_ssot.integrations.librenms.utils.librenms import LibreNMSApi +from nautobot_ssot.jobs.base import DataMapping, DataSource, DataTarget + +name = "LibreNMS SSoT" # pylint: disable=invalid-name + + +class LibrenmsDataSource(DataSource): + """LibreNMS SSoT Data Source.""" + + librenms_server = ObjectVar( + model=ExternalIntegration, + queryset=ExternalIntegration.objects.all(), + display_field="display", + required=True, + label="LibreNMS Instance", + ) + hostname_field = ChoiceVar( + choices=( + ("sysName", "sysName"), + ("hostname", "Hostname"), + ("env_var", "Environment Variable"), + ), + description="Which LibreNMS field to use as the name for imported device objects", + label="Hostname Field", + default="env_var", + ) + load_type = ChoiceVar( + choices=( + ("file", "file"), + ("api", "api"), + ), + description="Load LibreNMS from local fixutres or External Integration API.", + label="Data Load Source", + default="api", + ) + sync_locations = BooleanVar(description="Whether to Sync Locations from LibreNMS to Nautobot.", default=False) + tenant = ObjectVar( + model=Tenant, + queryset=Tenant.objects.all(), + description="Tenant to limit loading devices when syncing multiple LibreNMS Instances", + display_field="display", + label="Tenant Filter", + required=False, + ) + debug = BooleanVar(description="Enable for more verbose debug logging", default=False) + + class Meta: # pylint: disable=too-few-public-methods + """Meta data for LibreNMS.""" + + name = "LibreNMS to Nautobot" + data_source = "LibreNMS" + data_target = "Nautobot" + description = "Sync information from LibreNMS to Nautobot" + data_source_icon = static("nautobot_ssot_librenms/librenms.svg") + has_sensitive_variables = False + + @classmethod + def config_information(cls): + """Dictionary describing the configuration of this DataSource.""" + return { + "Instances": "Found in Extensibility -> External Integrations menu.", + "Hostname field in use": os.getenv("NAUTOBOT_SSOT_LIBRENMS_HOSTNAME_FIELD"), + } + + @classmethod + def data_mappings(cls): + """List describing the data mappings involved in this DataSource.""" + return ( + DataMapping("Location", "", "Location", "dcim.location"), + DataMapping("DeviceGroup", "", "Tag", "extras.tags"), + DataMapping("Device", "", "Device", "dcim.device"), + DataMapping("Port", "", "Interface", "dcim.interfaces"), + DataMapping("IP", "", "IPAddress", "ipam.ip_address"), + DataMapping("VLAN", "", "VLAN", "ipam.vlan"), + DataMapping("Manufacturer", "", "Manufacturer", "dcim.manufacturer"), + DataMapping("DeviceType", "", "DeviceType", "dcim.device_type"), + ) + + def load_source_adapter(self): + """Load data from LibreNMS into DiffSync models.""" + self.logger.info(f"Loading data from {self.librenms_server.name}") + if self.librenms_server.extra_config is None or "port" not in self.librenms_server.extra_config: + port = 443 + else: + port = self.librenms_server.extra_config["port"] + + _sg = self.librenms_server.secrets_group + token = _sg.get_secret_value( + access_type=SecretsGroupAccessTypeChoices.TYPE_HTTP, + secret_type=SecretsGroupSecretTypeChoices.TYPE_TOKEN, + ) + librenms_api = LibreNMSApi( + url=self.librenms_server.remote_url, + port=port, + token=token, + verify=self.librenms_server.verify_ssl, + ) + + self.source_adapter = librenms.LibrenmsAdapter(job=self, sync=self.sync, librenms_api=librenms_api) + self.source_adapter.load() + + def load_target_adapter(self): + """Load data from Nautobot into DiffSync models.""" + self.target_adapter = nautobot.NautobotAdapter(job=self, sync=self.sync, tenant=self.tenant) + self.target_adapter.load() + + def run( + self, + dryrun, + memory_profiling, + debug, + librenms_server, + hostname_field, + sync_locations, + tenant, + load_type, + *args, + **kwargs, + ): # pylint: disable=arguments-differ + """Perform data synchronization.""" + self.librenms_server = librenms_server + self.hostname_field = hostname_field + self.load_type = load_type + self.sync_locations = sync_locations + self.tenant = tenant + self.debug = debug + self.dryrun = dryrun + self.memory_profiling = memory_profiling + super().run(dryrun=self.dryrun, memory_profiling=self.memory_profiling, *args, **kwargs) + + +class LibrenmsDataTarget(DataTarget): + """LibreNMS SSoT Data Target.""" + + librenms_server = ObjectVar( + model=ExternalIntegration, + queryset=ExternalIntegration.objects.all(), + display_field="display", + required=True, + label="LibreNMS Instance", + ) + debug = BooleanVar(description="Enable for more verbose debug logging", default=False) + + class Meta: # pylint: disable=too-few-public-methods + """Meta data for LibreNMS.""" + + name = "Nautobot to LibreNMS" + data_source = "Nautobot" + data_target = "LibreNMS" + description = "Sync information from Nautobot to LibreNMS" + data_target_icon = static("nautobot_ssot_librenms/librenms.svg") + + @classmethod + def config_information(cls): + """Dictionary describing the configuration of this DataTarget.""" + return {} + + @classmethod + def data_mappings(cls): + """List describing the data mappings involved in this DataSource.""" + return () + + def load_source_adapter(self): + """Load data from Nautobot into DiffSync models.""" + self.source_adapter = nautobot.NautobotAdapter(job=self, sync=self.sync) + self.source_adapter.load() + + def load_target_adapter(self): + """Load data from LibreNMS into DiffSync models.""" + self.logger.info(f"Loading data from {self.librenms_server.name}") + if self.librenms_server.extra_config is None or "port" not in self.librenms_server.extra_config: + port = 443 + else: + port = self.librenms_server.extra_config["port"] + + _sg = self.librenms_server.secrets_group + token = _sg.get_secret_value( + access_type=SecretsGroupAccessTypeChoices.TYPE_HTTP, + secret_type=SecretsGroupSecretTypeChoices.TYPE_TOKEN, + ) + librenms_api = LibreNMSApi( + url=self.librenms_server.remote_url, + port=port, + token=token, + verify=self.librenms_server.verify_ssl, + ) + self.target_adapter = librenms.LibrenmsAdapter(job=self, sync=self.sync, librenms_api=librenms_api) + self.target_adapter.load() + + def run(self, dryrun, memory_profiling, debug, librenms_server, *args, **kwargs): # pylint: disable=arguments-differ + """Perform data synchronization.""" + self.librenms_server = librenms_server + self.debug = debug + self.dryrun = dryrun + self.memory_profiling = memory_profiling + super().run(dryrun=self.dryrun, memory_profiling=self.memory_profiling, *args, **kwargs) + + +jobs = [LibrenmsDataSource] +register_jobs(*jobs) diff --git a/nautobot_ssot/integrations/librenms/signals.py b/nautobot_ssot/integrations/librenms/signals.py new file mode 100644 index 000000000..f1997ea66 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/signals.py @@ -0,0 +1,110 @@ +"""Signals for LibreNMS SSoT.""" + +import importlib.util + +from nautobot.core.signals import nautobot_database_ready +from nautobot.extras.choices import CustomFieldTypeChoices + +from nautobot_ssot.utils import create_or_update_custom_field + +LIFECYCLE_MGMT = bool(importlib.util.find_spec("nautobot_device_lifecycle_mgmt")) + + +def register_signals(sender): + """Register signals for LibreNMS integration.""" + nautobot_database_ready.connect(nautobot_database_ready_callback, sender=sender) + + +def nautobot_database_ready_callback(sender, *, apps, **kwargs): # pylint: disable=unused-argument, too-many-statements + """Adds OS Version and Physical Address CustomField to Devices and System of Record and Last Sync'd to Device, and IPAddress. + + Callback function triggered by the nautobot_database_ready signal when the Nautobot database is fully ready. + """ + # pylint: disable=invalid-name, too-many-locals + ContentType = apps.get_model("contenttypes", "ContentType") + Manufacturer = apps.get_model("dcim", "Manufacturer") + DeviceType = apps.get_model("dcim", "DeviceType") + Device = apps.get_model("dcim", "Device") + Interface = apps.get_model("dcim", "Interface") + Platform = apps.get_model("dcim", "Platform") + Location = apps.get_model("dcim", "Location") + VLANGroup = apps.get_model("ipam", "VLANGroup") + VLAN = apps.get_model("ipam", "VLAN") + Prefix = apps.get_model("ipam", "Prefix") + IPAddress = apps.get_model("ipam", "IPAddress") + Role = apps.get_model("extras", "Role") + Tag = apps.get_model("extras", "Tag") + + signal_to_model_mapping = { + "manufacturer": Manufacturer, + "device_type": DeviceType, + "device": Device, + "interface": Interface, + "platform": Platform, + "role": Role, + "location": Location, + "vlan_group": VLANGroup, + "vlan": VLAN, + "prefix": Prefix, + "ip_address": IPAddress, + "tag": Tag, + } + + if LIFECYCLE_MGMT: + try: + SoftwareLCM = apps.get_model("nautobot_device_lifecycle_mgmt", "SoftwareLCM") + signal_to_model_mapping["software"] = SoftwareLCM + except LookupError as err: + print(f"Unable to find SoftwareLCM model from Device Lifecycle Management App. {err}") + try: + SoftwareImageLCM = apps.get_model("nautobot_device_lifecycle_mgmt", "SoftwareImageLCM") + signal_to_model_mapping["software_image"] = SoftwareImageLCM + except LookupError as err: + print(f"Unable to find SoftwareImageLCM model from Device Lifecycle Management App. {err}") + try: + ValidatedSoftwareLCM = apps.get_model("nautobot_device_lifecycle_mgmt", "ValidatedSoftwareLCM") + signal_to_model_mapping["validated_software"] = ValidatedSoftwareLCM + except LookupError as err: + print(f"Unable to find ValidatedSoftwareLCM model from Device Lifecycle Management App. {err}") + + sync_custom_field, _ = create_or_update_custom_field( + apps, + key="last_synced_from_sor", + field_type=CustomFieldTypeChoices.TYPE_DATE, + label="Last sync from System of Record", + ) + sor_custom_field, _ = create_or_update_custom_field( + apps, + key="system_of_record", + field_type=CustomFieldTypeChoices.TYPE_TEXT, + label="System of Record", + ) + CustomField = apps.get_model("extras", "CustomField") # pylint: disable=invalid-name + device_id_cf_dict = { + "type": CustomFieldTypeChoices.TYPE_INTEGER, + "key": "librenms_device_id", + "label": "LibreNMS Device ID", + "default": None, + "filter_logic": "exact", + } + device_id_custom_field, _ = CustomField.objects.update_or_create( + key=device_id_cf_dict["key"], defaults=device_id_cf_dict + ) + device_id_custom_field.content_types.add(ContentType.objects.get_for_model(signal_to_model_mapping["device"])) + + models_to_sync = [ + "device", + "interface", + "ip_address", + "manufacturer", + "device_type", + "tag", + ] + try: + for model in models_to_sync: + model = ContentType.objects.get_for_model(signal_to_model_mapping[model]) + sor_custom_field.content_types.add(model.id) + sync_custom_field.content_types.add(model.id) + except Exception as e: + print(f"Error occurred: {e}") + raise diff --git a/nautobot_ssot/integrations/librenms/static/nautobot_ssot_librenms/librenms.svg b/nautobot_ssot/integrations/librenms/static/nautobot_ssot_librenms/librenms.svg new file mode 100644 index 000000000..51b61ded5 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/static/nautobot_ssot_librenms/librenms.svg @@ -0,0 +1 @@ + diff --git a/nautobot_ssot/integrations/librenms/utils/__init__.py b/nautobot_ssot/integrations/librenms/utils/__init__.py new file mode 100644 index 000000000..6a589fe75 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/utils/__init__.py @@ -0,0 +1,53 @@ +"""Utility functions for working with LibreNMS and Nautobot.""" + +import inspect +import logging +import os + +from constance import config as constance_name +from django.conf import settings + +LOGGER = logging.getLogger(__name__) + + +def normalize_gps_coordinates(gps_coord): + """Normalize GPS Coordinates to 6 decimal places which is all that is stored in Nautobot.""" + return round(gps_coord, 6) + + +def normalize_setting(variable_name): + """Get a value from Django settings (if specified there) or Constance configuration (otherwise).""" + # Explicitly set in settings.py or nautobot_config.py takes precedence, for now + if variable_name.lower() in settings.PLUGINS_CONFIG["nautobot_ssot"]: + return settings.PLUGINS_CONFIG["nautobot_ssot"][variable_name.lower()] + return getattr(constance_name, f"{variable_name.upper()}") + + +def check_sor_field(model): + """Check if the System of Record field is present and is set to "LibreNMS".""" + return ( + "system_of_record" in model.custom_field_data + and model.custom_field_data["system_of_record"] is not None + and os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS") + in model.custom_field_data["system_of_record"] + ) + + +def get_sor_field_nautobot_object(nb_object): + """Get the System of Record field from an object.""" + _sor = "" + if "system_of_record" in nb_object.custom_field_data: + _sor = ( + nb_object.custom_field_data["system_of_record"] + if nb_object.custom_field_data["system_of_record"] is not None + else "" + ) + return _sor + + +def is_running_tests(): + """Check whether running unittests or actual job.""" + for frame in inspect.stack(): + if frame.filename.endswith("unittest/case.py"): + return True + return False diff --git a/nautobot_ssot/integrations/librenms/utils/librenms.py b/nautobot_ssot/integrations/librenms/utils/librenms.py new file mode 100644 index 000000000..f38b56c06 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/utils/librenms.py @@ -0,0 +1,197 @@ +"""Utility functions for working with LibreNMS.""" + +import json +import logging +import os + +import requests +import urllib3 + +LOGGER = logging.getLogger(__name__) + + +class ApiEndpoint: # pylint: disable=too-few-public-methods + """Base class to represent interactions with an API endpoint.""" + + class Meta: + """Meta data for ApiEndpoint class.""" + + abstract = True + + def __init__(self, url: str, port: int = 443, timeout: int = 30, verify: bool = True): + """Create API connection.""" + self.url = url + self.port = port + self.timeout = timeout + self.base_url = f"{self.url}:{self.port}" + self.verify = verify + self.headers = {"Accept": "*/*"} + self.params = {} + + if verify is False: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + def validate_url(self, path): + """Validate URL formatting is correct. + + Args: + path (str): URI path for API endpoint + + Returns: + str: Formatted URL path for API endpoint + """ + if not self.base_url.endswith("/") and not path.startswith("/"): + full_path = f"{self.base_url}/{path}" + else: + full_path = f"{self.base_url}{path}" + if not full_path.endswith("/"): + return full_path + return full_path + + def api_call(self, path: str, method: str = "GET", params: dict = {}, payload: dict = {}): # pylint: disable=dangerous-default-value + """Send Request to API endpoint of type `method`. Defaults to GET request. + + Args: + path (str): API path to send request to. + method (str, optional): API request method. Defaults to "GET". + params (dict, optional): Additional parameters to send to API. Defaults to None. + payload (dict, optional): Message payload to be sent as part of API call. + + Raises: + Exception: Error thrown if request errors. + + Returns: + dict: JSON payload of API response. + """ + url = self.validate_url(path) + + if not params: + params = self.params + else: + params = {**self.params, **params} + + resp = requests.request( + method=method, + headers=self.headers, + url=url, + params=params, + verify=self.verify, + data=payload, + timeout=self.timeout, + ) + try: + LOGGER.debug(f"LibreNMS Response: {resp}") + resp.raise_for_status() + + return resp.json() + except requests.exceptions.HTTPError as err: + LOGGER.log.error(f"Error in communicating to LibreNMS API: {err}") + raise Exception(f"Error communicating to the LibreNMS API: {err}") + + +class LibreNMSApi(ApiEndpoint): # pylint: disable=too-few-public-methods + """Representation of interactions with LibreNMS API.""" + + def __init__(self, url: str, token: str, port: int = 443, verify: bool = True): + """Create LibreNMS API connection.""" + super().__init__(url=url) + self.url = url + self.token = token + self.verify = verify + self.headers = {"Accept": "*/*", "X-Auth-Token": f"{self.token}"} + + LOGGER.info(f"Headers {self.headers}") + + def get_librenms_devices_from_file(self): # pylint: disable=no-self-use + """Get Devices from LibreNMS example file.""" + with open( + file=f"{os.getcwd()}/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json", + encoding="utf-8", + ) as API_CALL_FIXTURE: # pylint: disable=invalid-name + devices = json.load(API_CALL_FIXTURE) + return devices + + def get_librenms_locations_from_file(self): # pylint: disable=no-self-use + """Get Locations from LibreNMS example file.""" + with open( + file=f"{os.getcwd()}/nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json", + encoding="utf-8", + ) as API_CALL_FIXTURE: # pylint: disable=invalid-name + devices = json.load(API_CALL_FIXTURE) + return devices + + def get_librenms_devices(self): + """Get Devices from LibreNMS API endpoint.""" + url = "/api/v0/devices" + devices = self.api_call(path=url) + return devices + + def get_librenms_ports(self): + """Get Ports from LibreNMS API endpoint.""" + url = "/api/v0/ports" + ports = self.api_call(path=url) + return ports + + def get_librenms_port_detail(self, port_id: int): + """Get Port details from LibreNMS API endpoint.""" + url = "/api/v0/port/{port_id}" + port_details = self.api_call(path=url) + return port_details + + def get_librenms_locations(self): + """Get Location details from LibreNMS API endpoint.""" + url = "/api/v0/resources/locations" + locations = self.api_call(path=url) + return locations + + def get_librenms_device_groups(self): + """Get DeviceGroup details from LibreNMS API endpoint.""" + url = "/api/v0/devicegroups" + device_groups = self.api_call(path=url) + return device_groups + + def get_librenms_devices_by_device_group(self, group: str): + """Get Devices by DeviceGroup details from LibreNMS API endpoint.""" + url = "/api/v0/devicegroups/{group}" + devices = self.api_call(path=url) + return devices + + def get_librenms_device_groups_by_device(self, hostname: str): + """Get DeviceGroup by Device details from LibreNMS API endpoint.""" + url = "/api/v0/devices/{hostname}/groups" + device_groups = self.api_call(path=url) + return device_groups + + def get_librenms_ip_for_device(self, hostname: str): + """Get IP by Device details from LibreNMS API endpoint.""" + url = "/api/v0/devices/{hostname}/ip" + ips = self.api_call(path=url) + return ips + + def get_librenms_vrf(self): + """Get VRF details from LibreNMS API endpoint.""" + url = "/api/v0/routing/vrf" + vrf = self.api_call(path=url) + return vrf + + def get_librenms_vlans(self): + """Get VRF details from LibreNMS API endpoint.""" + url = "/api/v0/resources/vlans" + vlans = self.api_call(path=url) + return vlans + + def create_librenms_location(self, location: dict): + """Add Location details to LibreNMS API endpoint.""" + url = "/api/v0/locations" + method = "POST" + data = location + response = self.api_call(path=url, method=method, data=data) + return response + + def update_librenms_location(self, location: dict): + """Update Location details to LibreNMS API endpoint.""" + url = "/api/v0/locations" + method = "PATCH" + data = location + response = self.api_call(path=url, method=method, data=data) + return response diff --git a/nautobot_ssot/integrations/librenms/utils/nautobot.py b/nautobot_ssot/integrations/librenms/utils/nautobot.py new file mode 100644 index 000000000..504547694 --- /dev/null +++ b/nautobot_ssot/integrations/librenms/utils/nautobot.py @@ -0,0 +1,89 @@ +"""Utility functions for working with Nautobot.""" + +from uuid import UUID + +from django.contrib.contenttypes.models import ContentType +from nautobot.dcim.models import Device, Platform +from nautobot.extras.models import Relationship, RelationshipAssociation +from netutils.lib_mapper import ANSIBLE_LIB_MAPPER_REVERSE, NAPALM_LIB_MAPPER_REVERSE + +try: + from nautobot_device_lifecycle_mgmt.models import SoftwareLCM + + LIFECYCLE_MGMT = True +except ImportError: + LIFECYCLE_MGMT = False + + +def verify_platform(platform_name: str, manu: UUID) -> Platform: + """Verifies Platform object exists in Nautobot. If not, creates it. + + Args: + platform_name (str): Name of platform to verify. + manu (UUID): The ID (primary key) of platform manufacturer. + + Returns: + Platform: Found or created Platform object. + """ + if ANSIBLE_LIB_MAPPER_REVERSE.get(platform_name): + _name = ANSIBLE_LIB_MAPPER_REVERSE[platform_name] + else: + _name = platform_name + if NAPALM_LIB_MAPPER_REVERSE.get(platform_name): + napalm_driver = NAPALM_LIB_MAPPER_REVERSE[platform_name] + else: + napalm_driver = platform_name + try: + platform_obj = Platform.objects.get(network_driver=platform_name) + except Platform.DoesNotExist: + platform_obj = Platform( + name=_name, manufacturer_id=manu, napalm_driver=napalm_driver[:50], network_driver=platform_name + ) + platform_obj.validated_save() + return platform_obj + + +def add_software_lcm(diffsync, platform: str, version: str): + """Add OS Version as SoftwareLCM if Device Lifecycle Plugin found. + + Args: + diffsync (DiffSyncAdapter): DiffSync adapter with Job and maps. + platform (str): Name of platform to associate version to. + version (str): The software version to be created for specified platform. + + Returns: + UUID: UUID of the OS Version that is being found or created. + """ + platform_obj = Platform.objects.get(network_driver=platform) + try: + os_ver = SoftwareLCM.objects.get(device_platform=platform_obj, version=version).id + except SoftwareLCM.DoesNotExist: + diffsync.job.logger.info(f"Creating Version {version} for {platform}.") + os_ver = SoftwareLCM( + device_platform=platform_obj, + version=version, + ) + os_ver.validated_save() + os_ver = os_ver.id + return os_ver + + +def assign_version_to_device(diffsync, device: Device, software_lcm: UUID): + """Add Relationship between Device and SoftwareLCM.""" + try: + software_relation = Relationship.objects.get(label="Software on Device") + relationship = RelationshipAssociation.objects.get(relationship=software_relation, destination_id=device.id) + diffsync.job.logger.warning( + f"Deleting Software Version Relationships for {device.name} to assign a new version." + ) + relationship.delete() + except RelationshipAssociation.DoesNotExist: + pass + new_assoc = RelationshipAssociation( + relationship=software_relation, + source_type=ContentType.objects.get_for_model(SoftwareLCM), + source_id=software_lcm, + destination_type=ContentType.objects.get_for_model(Device), + destination_id=device.id, + ) + new_assoc.validated_save() diff --git a/nautobot_ssot/integrations/meraki/diffsync/models/nautobot.py b/nautobot_ssot/integrations/meraki/diffsync/models/nautobot.py index 5d9e1665f..4d525063f 100644 --- a/nautobot_ssot/integrations/meraki/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/meraki/diffsync/models/nautobot.py @@ -114,10 +114,16 @@ def create(cls, adapter, ids, attrs): return super().create(adapter=adapter, ids=ids, attrs=attrs) def delete(self): - """Delete DeviceType in Nautobot from NautobotHardware object.""" - super().delete() + """Delete SoftwareVersion in Nautobot from NautobotOSVersion object.""" osversion = SoftwareVersion.objects.get(id=self.uuid) - osversion.delete() + if hasattr(osversion, "validatedsoftwarelcm_set"): + if osversion.validatedsoftwarelcm_set.count() != 0: + self.adapter.job.logger.warning( + f"SoftwareVersion {osversion.version} for {osversion.platform.name} is used with a ValidatedSoftware so won't be deleted." + ) + else: + super().delete() + osversion.delete() return self diff --git a/nautobot_ssot/integrations/servicenow/diffsync/adapter_nautobot.py b/nautobot_ssot/integrations/servicenow/diffsync/adapter_nautobot.py index c2bc4680f..f6b95606c 100644 --- a/nautobot_ssot/integrations/servicenow/diffsync/adapter_nautobot.py +++ b/nautobot_ssot/integrations/servicenow/diffsync/adapter_nautobot.py @@ -59,7 +59,7 @@ def load_locations(self): """Load Nautobot Location objects as DiffSync Location models.""" if self.site_filter is not None: # Load only direct ancestors of the given Site - locations = [] + locations = [self.site_filter] ancestor = self.site_filter.parent while ancestor is not None: locations.insert(0, ancestor) diff --git a/nautobot_ssot/integrations/solarwinds/__init__.py b/nautobot_ssot/integrations/solarwinds/__init__.py new file mode 100644 index 000000000..78352050c --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/__init__.py @@ -0,0 +1 @@ +"""Base module for Solarwinds integration.""" diff --git a/nautobot_ssot/integrations/solarwinds/constants.py b/nautobot_ssot/integrations/solarwinds/constants.py new file mode 100644 index 000000000..15a2f2623 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/constants.py @@ -0,0 +1,24 @@ +"""Constants to be used with Solarwinds SSoT.""" + +ETH_INTERFACE_NAME_MAP = { + "AppGigabitEthernet": "virtual", + "FastEthernet": "100base-tx", + "GigabitEthernet": "1000base-t", + "FiveGigabitEthernet": "5gbase-t", + "TenGigabitEthernet": "10gbase-t", + "TwentyFiveGigE": "25gbase-x-sfp28", + "FortyGigabitEthernet": "40gbase-x-qsfpp", + "FiftyGigabitEthernet": "50gbase-x-sfp28", + "HundredGigE": "100gbase-x-qsfp28", +} + +ETH_INTERFACE_SPEED_MAP = { + "100Mbps": "100base-tx", + "1Gbps": "1000base-t", + "5Gbps": "5gbase-t", + "10Gbps": "10gbase-t", + "25Gbps": "25gbase-x-sfp28", + "40Gbps": "40gbase-x-qsfpp", + "50Gbps": "50gbase-x-sfp28", + "100Gbps": "100gbase-x-qsfp28", +} diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/__init__.py b/nautobot_ssot/integrations/solarwinds/diffsync/__init__.py new file mode 100644 index 000000000..4ab642209 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/__init__.py @@ -0,0 +1 @@ +"""DiffSync adapters and models for Solarwinds SSoT.""" diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/adapters/__init__.py b/nautobot_ssot/integrations/solarwinds/diffsync/adapters/__init__.py new file mode 100644 index 000000000..c816eeb51 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/adapters/__init__.py @@ -0,0 +1 @@ +"""Adapter classes for loading DiffSyncModels with data from Solarwinds or Nautobot.""" diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/adapters/nautobot.py b/nautobot_ssot/integrations/solarwinds/diffsync/adapters/nautobot.py new file mode 100644 index 000000000..522017830 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/adapters/nautobot.py @@ -0,0 +1,54 @@ +# pylint: disable=duplicate-code +"""Nautobot Adapter for Solarwinds SSoT app.""" + +from nautobot_ssot.contrib.adapter import NautobotAdapter as BaseNautobotAdapter +from nautobot_ssot.integrations.solarwinds.diffsync.models.base import ( + DeviceModel, + DeviceTypeModel, + InterfaceModel, + IPAddressModel, + LocationModel, + ManufacturerModel, + PlatformModel, + PrefixModel, + RoleModel, + SoftwareVersionModel, +) +from nautobot_ssot.integrations.solarwinds.diffsync.models.nautobot import ( + NautobotIPAddressToInterfaceModel, +) + + +class NautobotAdapter(BaseNautobotAdapter): + """DiffSync adapter for Nautobot.""" + + location = LocationModel + platform = PlatformModel + role = RoleModel + manufacturer = ManufacturerModel + device_type = DeviceTypeModel + softwareversion = SoftwareVersionModel + device = DeviceModel + interface = InterfaceModel + prefix = PrefixModel + ipaddress = IPAddressModel + ipassignment = NautobotIPAddressToInterfaceModel + + top_level = [ + "location", + "manufacturer", + "platform", + "role", + "softwareversion", + "device", + "prefix", + "ipaddress", + "ipassignment", + ] + + def load_param_mac_address(self, parameter_name, database_object): + """Custom loader for 'mac_address' parameter.""" + mac_addr = getattr(database_object, parameter_name) + if mac_addr is not None: + return str(mac_addr) + return mac_addr diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/adapters/solarwinds.py b/nautobot_ssot/integrations/solarwinds/diffsync/adapters/solarwinds.py new file mode 100644 index 000000000..7335abc30 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/adapters/solarwinds.py @@ -0,0 +1,508 @@ +"""Nautobot SSoT Solarwinds Adapter for Solarwinds SSoT app.""" + +import json +from datetime import datetime +from typing import Dict, List, Optional + +from diffsync import Adapter, DiffSyncModel +from diffsync.enum import DiffSyncModelFlags +from netutils.ip import ipaddress_interface, is_ip_within +from netutils.mac import mac_to_format + +from nautobot_ssot.integrations.solarwinds.diffsync.models.solarwinds import ( + SolarwindsDevice, + SolarwindsDeviceType, + SolarwindsInterface, + SolarwindsIPAddress, + SolarwindsIPAddressToInterface, + SolarwindsLocation, + SolarwindsManufacturer, + SolarwindsPlatform, + SolarwindsPrefix, + SolarwindsRole, + SolarwindsSoftwareVersion, +) +from nautobot_ssot.integrations.solarwinds.utils.solarwinds import ( + SolarwindsClient, + determine_role_from_devicetype, + determine_role_from_hostname, +) + + +class SolarwindsAdapter(Adapter): # pylint: disable=too-many-instance-attributes + """DiffSync adapter for Solarwinds.""" + + location = SolarwindsLocation + platform = SolarwindsPlatform + role = SolarwindsRole + manufacturer = SolarwindsManufacturer + device_type = SolarwindsDeviceType + softwareversion = SolarwindsSoftwareVersion + device = SolarwindsDevice + interface = SolarwindsInterface + prefix = SolarwindsPrefix + ipaddress = SolarwindsIPAddress + ipassignment = SolarwindsIPAddressToInterface + + top_level = [ + "location", + "manufacturer", + "platform", + "role", + "softwareversion", + "device", + "prefix", + "ipaddress", + "ipassignment", + ] + + def __init__( # pylint: disable=too-many-arguments + self, + client: SolarwindsClient, + containers, + location_type, + job, + sync=None, + parent=None, + tenant=None, + ): + """Initialize Solarwinds. + + Args: + job (object, optional): Solarwinds job. Defaults to None. + sync (object, optional): SolarwindsDataSource Sync. Defaults to None. + client (SolarwindsClient): Solarwinds API client connection object. + containers (str): Concatenated string of Container names to be imported. Will be 'ALL' for all containers. + location_type (LocationType): The LocationType to create containers as in Nautobot. + parent (Location, optional): The parent Location to assign created containers to in Nautobot. + tenant (Tenant, optional): The Tenant to associate with Devices and IPAM data. + """ + super().__init__() + self.job = job + self.sync = sync + self.conn = client + self.containers = containers + self.location_type = location_type + self.parent = parent + self.tenant = tenant + self.failed_devices = [] + + def load(self): # pylint: disable=too-many-locals, too-many-branches, too-many-statements + """Load data from Solarwinds into DiffSync models.""" + self.job.logger.info("Loading data from Solarwinds.") + + if self.parent: + self.load_parent() + + if self.job.pull_from == "CustomProperty" and self.job.location_override: + container_nodes = self.get_nodes_custom_property( + custom_property=self.job.custom_property, location=self.job.location_override + ) + else: + container_nodes = self.get_container_nodes(custom_property=self.job.custom_property) + + self.load_sites(container_nodes) + + node_details = {} + for container_name, nodes in container_nodes.items(): # pylint: disable=too-many-nested-blocks + self.job.logger.debug(f"Retrieving node details from Solarwinds for {container_name}.") + node_details = self.conn.build_node_details(nodes=nodes) + for node in node_details.values(): + device_type = self.conn.standardize_device_type(node=node) + role = self.determine_device_role(node, device_type) + self.load_role(role) + if device_type: + platform_name = self.load_platform(device_type, manufacturer=node.get("Vendor")) + if platform_name == "UNKNOWN": + self.job.logger.error(f"Can't determine platform for {node['NodeHostname']} so skipping load.") + self.failed_devices.append({**node, **{"error": "Unable to determine Platform."}}) + continue + if node.get("Vendor") and node["Vendor"] != "net-snmp": + self.load_manufacturer_and_device_type(manufacturer=node["Vendor"], device_type=device_type) + version = self.conn.extract_version(version=node["Version"]) if node.get("Version") else "" + if version: + self.get_or_instantiate( + self.softwareversion, + ids={"version": version, "platform__name": platform_name, "status__name": "Active"}, + attrs={}, + ) + new_dev, loaded = self.get_or_instantiate( + self.device, + ids={ + "name": node["NodeHostname"], + }, + attrs={ + "device_type__manufacturer__name": node["Vendor"], + "device_type__model": device_type, + "location__name": container_name, + "location__location_type__name": self.location_type.name, + "platform__name": platform_name, + "role__name": role, + "snmp_location": node["SNMPLocation"] if node.get("SNMPLocation") else None, + "software_version__version": version if version else None, + "software_version__platform__name": platform_name if version else None, + "last_synced_from_sor": datetime.today().date().isoformat(), + "status__name": "Active", + "serial": node["ServiceTag"] if node.get("ServiceTag") else "", + "tenant__name": self.tenant.name if self.tenant else None, + "system_of_record": "Solarwinds", + }, + ) + if loaded: + if node.get("interfaces"): + self.load_interfaces(device=new_dev, intfs=node["interfaces"]) + if not node.get("ipaddrs") or ( + node.get("ipaddrs") and node["IPAddress"] not in node["ipaddrs"] + ): + prefix = ipaddress_interface( + ip=f"{node['IPAddress']}/{node['PFLength']}", attr="network" + ).with_prefixlen + self.load_prefix(network=prefix) + self.load_ipaddress( + addr=node["IPAddress"], + prefix_length=node["PFLength"], + prefix=prefix, + addr_type="IPv6" if ":" in node["IPAddress"] else "IPv4", + ) + self.load_interfaces( + device=new_dev, + intfs={1: {"Name": "Management", "Enabled": "Up", "Status": "Up"}}, + ) + self.load_ipassignment( + addr=node["IPAddress"], + dev_name=new_dev.name, + intf_name="Management", + addr_type="IPv6" if ":" in node["IPAddress"] else "IPv4", + mgmt_addr=node["IPAddress"], + ) + if node.get("ipaddrs"): + for _, ipaddr in node["ipaddrs"].items(): + pf_len = ipaddr["SubnetMask"] + prefix = ipaddress_interface( + f"{ipaddr['IPAddress']}/{pf_len}", "network" + ).with_prefixlen + self.load_prefix(network=prefix) + self.load_ipaddress( + addr=ipaddr["IPAddress"], + prefix_length=pf_len, + prefix=prefix, + addr_type=ipaddr["IPAddressType"], + ) + if ipaddr["IntfName"] not in node["interfaces"]: + self.load_interfaces( + device=new_dev, + intfs={1: {"Name": ipaddr["IntfName"], "Enabled": "Up", "Status": "Up"}}, + ) + self.load_ipassignment( + addr=ipaddr["IPAddress"], + dev_name=new_dev.name, + intf_name=ipaddr["IntfName"], + addr_type=ipaddr["IPAddressType"], + mgmt_addr=node["IPAddress"], + ) + else: + if node.get("Vendor") and node["Vendor"] == "net-snmp": + self.job.logger.error(f"{node['NodeHostname']} is showing as net-snmp so won't be imported.") + else: + self.job.logger.error(f"{node['NodeHostname']} is missing DeviceType so won't be imported.") + self.failed_devices.append({**node, **{"error": "Unable to determine DeviceType."}}) + + self.reprocess_ip_parent_prefixes() + if node_details and self.job.debug: + self.job.logger.debug(f"Node details: {json.dumps(node_details, indent=2)}") + if self.failed_devices: + self.job.logger.warning( + f"List of {len(self.failed_devices)} devices that were unable to be loaded. {json.dumps(self.failed_devices, indent=2)}" + ) + + def load_manufacturer_and_device_type(self, manufacturer: str, device_type: str): + """Load Manufacturer and DeviceType into DiffSync models. + + Args: + manufacturer (str): Name of manufacturer to be loaded. + device_type (str): DeviceType to be loaded. + """ + manu, _ = self.get_or_instantiate(self.manufacturer, ids={"name": manufacturer}, attrs={}) + new_dt, loaded = self.get_or_instantiate( + self.device_type, + ids={"model": device_type, "manufacturer__name": manufacturer}, + attrs={}, + ) + if loaded: + manu.add_child(new_dt) + + def get_nodes_custom_property(self, custom_property, location): + """Gather nodes with customproperty from Solarwinds.""" + nodes = {location.name: self.conn.get_nodes_custom_property(custom_property)} + return nodes + + def get_container_nodes(self, custom_property=None): + """Gather container nodes for all specified containers from Solarwinds.""" + container_ids, container_nodes = {}, {} + if self.containers != "ALL": + container_ids = self.conn.get_filtered_container_ids(containers=self.containers) + else: + container_ids = self.conn.get_top_level_containers(top_container=self.job.top_container) + container_nodes = self.conn.get_container_nodes(container_ids, custom_property) + return container_nodes + + def load_location( # pylint: disable=too-many-arguments + self, + loc_name: str, + location_type: str, + status: str, + parent_name: Optional[str] = None, + parent_type: Optional[str] = None, + parent_parent_name: Optional[str] = None, + parent_parent_type: Optional[str] = None, + ) -> tuple: + """Load location into DiffSync model. + + Args: + loc_name (str): Location name to load. + location_type (str): LocationType for Location to be loaded. + parent_name (str, optional): Name for parent of Location. Defaults to None. + parent_type (str, optional): LocationType for parent of Location. Defaults to None. + parent_parent_name (str, optional): Name for parent of parent of Location. Defaults to None. + parent_parent_type (str, optional): LocationType for parent of parent of Location. Defaults to None. + status (str): Status of Location to be loaded. + + Returns: + tuple: Location DiffSync model and if it was loaded. + """ + location, loaded = self.get_or_instantiate( + self.location, + ids={ + "name": loc_name, + "location_type__name": location_type, + "parent__name": parent_name, + "parent__location_type__name": parent_type, + "parent__parent__name": parent_parent_name, + "parent__parent__location_type__name": parent_parent_type, + }, + attrs={"status__name": status}, + ) + + return (location, loaded) + + def load_parent(self): + """Function to load parent Location into Location DiffSync model.""" + parent, loaded = self.load_location( + loc_name=self.parent.name, + location_type=self.parent.location_type.name, + status=self.parent.status.name, + parent_name=self.parent.parent.name if self.parent and self.parent.parent else None, + parent_type=self.parent.parent.location_type.name if self.parent and self.parent.parent else None, + parent_parent_name=( + self.parent.parent.parent.name + if self.parent and self.parent.parent and self.parent.parent.parent + else None + ), + parent_parent_type=( + self.parent.parent.parent.location_type.name + if self.parent and self.parent.parent and self.parent.parent.parent + else None + ), + ) + if loaded: + parent.model_flags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + def load_sites(self, container_nodes: Dict[str, List[dict]]): + """Load containers as LocationType into Location DiffSync models. + + Args: + container_nodes (Dict[str, List[dict]]): Dictionary of Container to list of dictionaries containing nodes within that container. + """ + for container_name, node_list in container_nodes.items(): + self.job.logger.debug(f"Found {len(node_list)} nodes for {container_name} container.") + self.load_location( + loc_name=container_name, + location_type=self.location_type.name, + parent_name=self.parent.name if self.parent else None, + parent_type=self.parent.location_type.name if self.parent else None, + parent_parent_name=self.parent.parent.name if self.parent and self.parent.parent else None, + parent_parent_type=( + self.parent.parent.location_type.name if self.parent and self.parent.parent else None + ), + status="Active", + ) + + def determine_device_role(self, node: dict, device_type: str) -> str: + """Determine Device Role based upon role_choice setting. + + Args: + node (dict): Dictionary of Node details. + device_type (str): DeviceType model. + + Returns: + str: Device Role from DeviceType, Hostname, or default Role. + """ + role = "" + if self.job.role_map and self.job.role_choice == "DeviceType": + role = determine_role_from_devicetype(device_type=device_type, role_map=self.job.role_map) + if self.job.role_map and self.job.role_choice == "Hostname": + role = determine_role_from_hostname(hostname=node["NodeHostname"], role_map=self.job.role_map) + if not role: + role = self.job.default_role.name + return role + + def load_role(self, role): + """Load passed Role into DiffSync model.""" + self.get_or_instantiate( + self.role, ids={"name": role}, attrs={"content_types": [{"app_label": "dcim", "model": "device"}]} + ) + + def load_platform(self, device_type: str, manufacturer: str): # pylint: disable=inconsistent-return-statements + """Load Platform into DiffSync model based upon DeviceType. + + Args: + device_type (str): DeviceType name for associated Platform. + manufacturer (str): Manufacturer name for associated Platform. + """ + if "Aruba" in manufacturer: + self.get_or_instantiate( + self.platform, + ids={"name": "arubanetworks.aoscx", "manufacturer__name": manufacturer}, + attrs={"network_driver": "aruba_aoscx", "napalm_driver": ""}, + ) + return "arubanetworks.aoscx" + if "Cisco" in manufacturer: + if not device_type.startswith("N"): + self.get_or_instantiate( + self.platform, + ids={"name": "cisco.ios.ios", "manufacturer__name": manufacturer}, + attrs={"network_driver": "cisco_ios", "napalm_driver": "ios"}, + ) + return "cisco.ios.ios" + if device_type.startswith("N"): + self.get_or_instantiate( + self.platform, + ids={"name": "cisco.nxos.nxos", "manufacturer__name": manufacturer}, + attrs={"network_driver": "cisco_nxos", "napalm_driver": "nxos"}, + ) + return "cisco.nxos.nxos" + elif "Palo" in manufacturer: + self.get_or_instantiate( + self.platform, + ids={"name": "paloaltonetworks.panos.panos", "manufacturer__name": manufacturer}, + attrs={"network_driver": "paloalto_panos", "napalm_driver": ""}, + ) + return "paloaltonetworks.panos.panos" + return "UNKNOWN" + + def load_interfaces(self, device: DiffSyncModel, intfs: dict) -> None: + """Load interfaces for passed device. + + Args: + device (DiffSyncModel): DiffSync Device model that's been loaded. + intfs (dict): Interface data for Device. + """ + for _, intf in intfs.items(): + new_intf, loaded = self.get_or_instantiate( + self.interface, + ids={"name": intf["Name"], "device__name": device.name}, + attrs={ + "enabled": bool(intf["Enabled"] == "Up"), + "mac_address": mac_to_format(intf["MAC"], "MAC_COLON_TWO") if intf.get("MAC") else None, + "mtu": intf["MTU"] if intf.get("MTU") else 1500, + "type": self.conn.determine_interface_type(interface=intf), + "status__name": "Active" if intf["Status"] == "Up" else "Failed", + }, + ) + if loaded: + device.add_child(new_intf) + + def reprocess_ip_parent_prefixes(self) -> None: + """Check for an existing more specific prefix. + + Runs after loading all data to ensure IP's have appropriate parent prefixes. + """ + for ipaddr in self.get_all(obj="ipaddress"): + parent_subnet = f"{ipaddr.parent__network}/{ipaddr.parent__prefix_length}" + for prefix in self.get_all(obj="prefix"): + if not prefix.namespace__name == ipaddr.parent__namespace__name: + continue + subnet = f"{prefix.network}/{prefix.prefix_length}" + if not is_ip_within(parent_subnet, subnet): + if is_ip_within(ipaddr.host, subnet): + if self.job.debug: + self.job.logger.debug( + "More specific subnet %s found for IP %s/%s", subnet, ipaddr.host, ipaddr.mask_length + ) + ipaddr.parent__network = prefix.network + ipaddr.parent__prefix_length = prefix.prefix_length + self.update(ipaddr) + + def load_prefix(self, network: str) -> None: + """Load Prefix for passed network. + + Args: + network (str): Prefix network to be loaded. + """ + self.get_or_instantiate( + self.prefix, + ids={ + "network": network.split("/")[0], + "prefix_length": network.split("/")[1], + "namespace__name": self.tenant.name if self.tenant else "Global", + }, + attrs={ + "status__name": "Active", + "tenant__name": self.tenant.name if self.tenant else None, + "last_synced_from_sor": datetime.today().date().isoformat(), + "system_of_record": "Solarwinds", + }, + ) + + def load_ipaddress(self, addr: str, prefix_length: int, prefix: str, addr_type: str) -> None: + """Load IPAddress for passed address. + + Args: + addr (str): Host for IPAddress. + prefix_length (int): Prefix length for IPAddress. + prefix (str): Parent prefix CIDR for IPAddress. + addr_type (str): Either "IPv4" or "IPv6" + """ + self.get_or_instantiate( + self.ipaddress, + ids={ + "host": addr, + "parent__network": prefix.split("/")[0], + "parent__prefix_length": prefix_length, + "parent__namespace__name": self.tenant.name if self.tenant else "Global", + }, + attrs={ + "mask_length": prefix_length, + "status__name": "Active", + "ip_version": 4 if addr_type == "IPv4" else 6, + "tenant__name": self.tenant.name if self.tenant else None, + "last_synced_from_sor": datetime.today().date().isoformat(), + "system_of_record": "Solarwinds", + }, + ) + + def load_ipassignment( # pylint: disable=too-many-arguments + self, + addr: str, + dev_name: str, + intf_name: str, + addr_type: str, + mgmt_addr: str, + ) -> None: + """Load IPAddress for passed address. + + Args: + addr (str): Host for IPAddress. + dev_name (str): Device name for associated Interface. + intf_name (str): Interface name to associate IPAddress to. + addr_type (str): Either "IPv4" or "IPv6" + mgmt_addr (str): Management IP Address for Device. + """ + self.get_or_instantiate( + self.ipassignment, + ids={"interface__device__name": dev_name, "interface__name": intf_name, "ip_address__host": addr}, + attrs={ + "interface__device__primary_ip4__host": mgmt_addr if addr_type == "IPv4" else None, + "interface__device__primary_ip6__host": mgmt_addr if addr_type == "IPv6" else None, + }, + ) diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/models/__init__.py b/nautobot_ssot/integrations/solarwinds/diffsync/models/__init__.py new file mode 100644 index 000000000..d768b5ad2 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/models/__init__.py @@ -0,0 +1 @@ +"""DiffSync models and adapters for the Solarwinds SSoT app.""" diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/models/base.py b/nautobot_ssot/integrations/solarwinds/diffsync/models/base.py new file mode 100644 index 000000000..d35a07378 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/models/base.py @@ -0,0 +1,290 @@ +# pylint: disable=R0801 +"""DiffSyncModel subclasses for Nautobot-to-Solarwinds data sync.""" + +try: + from typing import Annotated # Python>=3.9 +except ImportError: + from typing_extensions import Annotated # Python<3.9 + +from typing import List, Optional + +from diffsync.enum import DiffSyncModelFlags +from nautobot.dcim.models import Device, DeviceType, Interface, Location, Manufacturer, Platform, SoftwareVersion +from nautobot.extras.models import Role +from nautobot.ipam.models import IPAddress, IPAddressToInterface, Prefix + +from nautobot_ssot.contrib.model import NautobotModel +from nautobot_ssot.contrib.types import CustomFieldAnnotation +from nautobot_ssot.tests.contrib_base_classes import ContentTypeDict + + +class LocationModel(NautobotModel): + """Diffsync model for Solarwinds containers.""" + + model_flags: DiffSyncModelFlags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + _model = Location + _modelname = "location" + _identifiers = ( + "name", + "location_type__name", + "parent__name", + "parent__location_type__name", + "parent__parent__name", + "parent__parent__location_type__name", + ) + _attributes = ("status__name",) + _children = {} + + name: str + location_type__name: str + status__name: str + parent__name: Optional[str] = None + parent__location_type__name: Optional[str] = None + parent__parent__name: Optional[str] = None + parent__parent__location_type__name: Optional[str] = None + + +class DeviceTypeModel(NautobotModel): + """DiffSync model for Solarwinds device types.""" + + model_flags: DiffSyncModelFlags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + _model = DeviceType + _modelname = "device_type" + _identifiers = ("model", "manufacturer__name") + + model: str + manufacturer__name: str + + +class ManufacturerModel(NautobotModel): + """DiffSync model for Solarwinds device manufacturers.""" + + model_flags: DiffSyncModelFlags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + _model = Manufacturer + _modelname = "manufacturer" + _identifiers = ("name",) + _children = {"device_type": "device_types"} + + name: str + device_types: List[DeviceTypeModel] = [] + + +class PlatformModel(NautobotModel): + """Shared data model representing a Platform in either of the local or remote Nautobot instances.""" + + model_flags: DiffSyncModelFlags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + _model = Platform + _modelname = "platform" + _identifiers = ("name", "manufacturer__name") + _attributes = ("network_driver", "napalm_driver") + + name: str + manufacturer__name: str + network_driver: str + napalm_driver: str + + +class RoleModel(NautobotModel): + """DiffSync model for Solarwinds Device roles.""" + + model_flags: DiffSyncModelFlags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + _model = Role + _modelname = "role" + _identifiers = ("name",) + _attributes = ("content_types",) + + name: str + content_types: List[ContentTypeDict] = [] + + +class SoftwareVersionModel(NautobotModel): + """DiffSync model for Solarwinds Device Software versions.""" + + model_flags: DiffSyncModelFlags = DiffSyncModelFlags.SKIP_UNMATCHED_DST + + _model = SoftwareVersion + _modelname = "softwareversion" + _identifiers = ("version", "platform__name") + _attributes = ("status__name",) + + version: str + platform__name: str + status__name: str + + +class DeviceModel(NautobotModel): + """DiffSync model for Solarwinds devices.""" + + _model = Device + _modelname = "device" + _identifiers = ("name",) + _attributes = ( + "status__name", + "device_type__manufacturer__name", + "device_type__model", + "location__name", + "location__location_type__name", + "platform__name", + "role__name", + "serial", + "snmp_location", + "software_version__version", + "software_version__platform__name", + "last_synced_from_sor", + "system_of_record", + "tenant__name", + ) + _children = {"interface": "interfaces"} + + name: str + device_type__manufacturer__name: str + device_type__model: str + location__name: str + location__location_type__name: str + platform__name: str + role__name: str + serial: str + software_version__version: Optional[str] = None + software_version__platform__name: Optional[str] = None + status__name: str + tenant__name: Optional[str] = None + + interfaces: Optional[List["InterfaceModel"]] = [] + + snmp_location: Annotated[Optional[str], CustomFieldAnnotation(name="snmp_location")] = None + system_of_record: Annotated[Optional[str], CustomFieldAnnotation(name="system_of_record")] = None + last_synced_from_sor: Annotated[Optional[str], CustomFieldAnnotation(name="last_synced_from_sor")] = None + + @classmethod + def get_queryset(cls): + """Return only Devices with system_of_record set to Solarwinds.""" + return Device.objects.filter(_custom_field_data__system_of_record="Solarwinds") + + +class InterfaceModel(NautobotModel): + """Shared data model representing an Interface.""" + + # Metadata about this model + _model = Interface + _modelname = "interface" + _identifiers = ("name", "device__name") + _attributes = ( + "enabled", + "mac_address", + "mtu", + "type", + "status__name", + ) + _children = {} + + name: str + device__name: str + enabled: bool + mac_address: Optional[str] = None + mtu: int + type: str + status__name: str + + @classmethod + def get_queryset(cls): + """Return only Interfaces with system_of_record set to Solarwinds.""" + return Interface.objects.filter(device___custom_field_data__system_of_record="Solarwinds") + + +class PrefixModel(NautobotModel): + """Shared data model representing a Prefix.""" + + # Metadata about this model + _model = Prefix + _modelname = "prefix" + _identifiers = ( + "network", + "prefix_length", + "namespace__name", + ) + _attributes = ( + "status__name", + "tenant__name", + "last_synced_from_sor", + "system_of_record", + ) + + # Data type declarations for all identifiers and attributes + network: str + prefix_length: int + status__name: str + tenant__name: Optional[str] = None + namespace__name: str + system_of_record: Annotated[Optional[str], CustomFieldAnnotation(name="system_of_record")] = None + last_synced_from_sor: Annotated[Optional[str], CustomFieldAnnotation(name="last_synced_from_sor")] = None + + @classmethod + def get_queryset(cls): + """Return only Prefixes with system_of_record set to Solarwinds.""" + return Prefix.objects.filter(_custom_field_data__system_of_record="Solarwinds") + + +class IPAddressModel(NautobotModel): + """Shared data model representing an IPAddress.""" + + _model = IPAddress + _modelname = "ipaddress" + _identifiers = ( + "host", + "parent__network", + "parent__prefix_length", + "parent__namespace__name", + ) + _attributes = ( + "mask_length", + "status__name", + "ip_version", + "tenant__name", + "last_synced_from_sor", + "system_of_record", + ) + + host: str + mask_length: int + parent__network: str + parent__prefix_length: int + parent__namespace__name: str + status__name: str + ip_version: int + tenant__name: Optional[str] = None + system_of_record: Annotated[Optional[str], CustomFieldAnnotation(name="system_of_record")] = None + last_synced_from_sor: Annotated[Optional[str], CustomFieldAnnotation(name="last_synced_from_sor")] = None + + @classmethod + def get_queryset(cls): + """Return only IP Addresses with system_of_record set to Solarwinds.""" + return IPAddress.objects.filter(_custom_field_data__system_of_record="Solarwinds") + + +class IPAddressToInterfaceModel(NautobotModel): + """Shared data model representing an IPAddressToInterface.""" + + _model = IPAddressToInterface + _modelname = "ipassignment" + _identifiers = ("interface__device__name", "interface__name", "ip_address__host") + _attributes = ( + "interface__device__primary_ip4__host", + "interface__device__primary_ip6__host", + ) + _children = {} + + interface__device__name: str + interface__name: str + ip_address__host: str + interface__device__primary_ip4__host: Optional[str] = None + interface__device__primary_ip6__host: Optional[str] = None + + @classmethod + def get_queryset(cls): + """Return only IPAddressToInterface with system_of_record set to Solarwinds.""" + return IPAddressToInterface.objects.filter(interface__device___custom_field_data__system_of_record="Solarwinds") diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/models/nautobot.py b/nautobot_ssot/integrations/solarwinds/diffsync/models/nautobot.py new file mode 100644 index 000000000..e0ef2457d --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/models/nautobot.py @@ -0,0 +1,72 @@ +# pylint: disable=no-member +"""Nautobot DiffSync models for Solarwinds SSoT.""" + +from nautobot.dcim.models import Interface +from nautobot.ipam.models import IPAddress, IPAddressToInterface + +from nautobot_ssot.integrations.solarwinds.diffsync.models.base import IPAddressToInterfaceModel + + +class NautobotIPAddressToInterfaceModel(IPAddressToInterfaceModel): + """IPAddressToInterface model for Nautobot.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create IPAddressToInterface in Nautobot.""" + if adapter.job.debug: + adapter.job.logger.debug(f"Creating IPAddressToInterface {ids} {attrs}") + intf = Interface.objects.get(name=ids["interface__name"], device__name=ids["interface__device__name"]) + + # try: + obj = IPAddressToInterface( + ip_address=IPAddress.objects.get(host=ids["ip_address__host"], tenant=intf.device.tenant), + interface=intf, + ) + obj.validated_save() + # except IPAddress.DoesNotExist as e: + # print(f"IP: {ids=}, {intf=}, {intf.device=}") + + if ( + attrs.get("interface__device__primary_ip4__host") + and ids["ip_address__host"] == attrs["interface__device__primary_ip4__host"] + ): + obj.interface.device.primary_ip4 = IPAddress.objects.get( + host=attrs["interface__device__primary_ip4__host"], + tenant=obj.interface.device.tenant, + ) + obj.interface.device.validated_save() + if ( + attrs.get("interface__device__primary_ip6__host") + and ids["ip_address__host"] == attrs["interface__device__primary_ip6__host"] + ): + obj.interface.device.primary_ip6 = IPAddress.objects.get( + host=attrs["interface__device__primary_ip6__host"], + tenant=obj.interface.device.tenant, + ) + obj.interface.device.validated_save() + return super().create_base(adapter=adapter, ids=ids, attrs=attrs) + + def update(self, attrs): + """Update IPAddressToInterface in Nautobot.""" + obj = self.get_from_db() + if ( + attrs.get("interface__device__primary_ip4__host") + and self.ip_address__host == attrs["interface__device__primary_ip4__host"] + ): + obj.interface.device.primary_ip4 = IPAddress.objects.get( + host=attrs["interface__device__primary_ip4__host"], tenant=obj.interface.device.tenant + ) + obj.interface.device.validated_save() + if ( + attrs.get("interface__device__primary_ip6__host") + and self.ip_address__host == attrs["interface__device__primary_ip6__host"] + ): + obj.interface.device.primary_ip6 = IPAddress.objects.get( + host=attrs["interface__device__primary_ip6__host"], tenant=obj.interface.device.tenant + ) + obj.interface.device.validated_save() + return super().update_base(attrs) + + def delete(self): + """Delete IPAddressToInterface in Nautobot.""" + return super().delete_base() diff --git a/nautobot_ssot/integrations/solarwinds/diffsync/models/solarwinds.py b/nautobot_ssot/integrations/solarwinds/diffsync/models/solarwinds.py new file mode 100644 index 000000000..0762df133 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/diffsync/models/solarwinds.py @@ -0,0 +1,202 @@ +"""Nautobot SSoT Solarwinds DiffSync models for Nautobot SSoT Solarwinds SSoT.""" + +from nautobot_ssot.integrations.solarwinds.diffsync.models.base import ( + DeviceModel, + DeviceTypeModel, + InterfaceModel, + IPAddressModel, + IPAddressToInterfaceModel, + LocationModel, + ManufacturerModel, + PlatformModel, + PrefixModel, + RoleModel, + SoftwareVersionModel, +) + + +class SolarwindsLocation(LocationModel): + """Solarwinds implementation of Location DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Location in Solarwinds from SolarwindsLocation object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Location in Solarwinds from SolarwindsLocation object.""" + raise NotImplementedError + + def delete(self): + """Delete Location in Solarwinds from SolarwindsLocation object.""" + raise NotImplementedError + + +class SolarwindsDeviceType(DeviceTypeModel): + """Solarwinds implementation of DeviceType DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create DeviceType in Solarwinds from SolarwindsDeviceType object.""" + raise NotImplementedError + + def update(self, attrs): + """Update DeviceType in Solarwinds from SolarwindsDeviceType object.""" + raise NotImplementedError + + def delete(self): + """Delete DeviceType in Solarwinds from SolarwindsDeviceType object.""" + raise NotImplementedError + + +class SolarwindsManufacturer(ManufacturerModel): + """Solarwinds implementation of Manufacturer DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Manufacturer in Solarwinds from SolarwindsManufacturer object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Manufacturer in Solarwinds from SolarwindsManufacturer object.""" + raise NotImplementedError + + def delete(self): + """Delete Manufacturer in Solarwinds from SolarwindsManufacturer object.""" + raise NotImplementedError + + +class SolarwindsPlatform(PlatformModel): + """Solarwinds implementation of Platform DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Platform in Solarwinds from SolarwindsPlatform object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Platform in Solarwinds from SolarwindsPlatform object.""" + raise NotImplementedError + + def delete(self): + """Delete Platform in Solarwinds from SolarwindsPlatform object.""" + raise NotImplementedError + + +class SolarwindsSoftwareVersion(SoftwareVersionModel): + """Solarwinds implementation of SoftwareVersion DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create SoftwareVersion in Solarwinds from SolarwindsSoftwareVersion object.""" + raise NotImplementedError + + def update(self, attrs): + """Update SoftwareVersion in Solarwinds from SolarwindsSoftwareVersion object.""" + raise NotImplementedError + + def delete(self): + """Delete SoftwareVersion in Solarwinds from SolarwindsSoftwareVersion object.""" + raise NotImplementedError + + +class SolarwindsRole(RoleModel): + """Solarwinds implementation of Role DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Role in Solarwinds from SolarwindsRole object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Role in Solarwinds from SolarwindsRole object.""" + raise NotImplementedError + + def delete(self): + """Delete Role in Solarwinds from SolarwindsRole object.""" + raise NotImplementedError + + +class SolarwindsDevice(DeviceModel): + """Solarwinds implementation of Device DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Device in Solarwinds from SolarwindsDevice object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Device in Solarwinds from SolarwindsDevice object.""" + raise NotImplementedError + + def delete(self): + """Delete Device in Solarwinds from SolarwindsDevice object.""" + raise NotImplementedError + + +class SolarwindsInterface(InterfaceModel): + """Solarwinds implementation of Interface DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Interface in Solarwinds from SolarwindsInterface object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Interface in Solarwinds from SolarwindsInterface object.""" + raise NotImplementedError + + def delete(self): + """Delete Interface in Solarwinds from SolarwindsInterface object.""" + raise NotImplementedError + + +class SolarwindsPrefix(PrefixModel): + """Solarwinds implementation of Prefix DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create Prefix in Solarwinds from SolarwindsPrefix object.""" + raise NotImplementedError + + def update(self, attrs): + """Update Prefix in Solarwinds from SolarwindsPrefix object.""" + raise NotImplementedError + + def delete(self): + """Delete Prefix in Solarwinds from SolarwindsPrefix object.""" + raise NotImplementedError + + +class SolarwindsIPAddress(IPAddressModel): + """Solarwinds implementation of IPAddress DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create IPAddress in Solarwinds from SolarwindsIPAddress object.""" + raise NotImplementedError + + def update(self, attrs): + """Update IPAddress in Solarwinds from SolarwindsIPAddress object.""" + raise NotImplementedError + + def delete(self): + """Delete IPAddress in Solarwinds from SolarwindsIPAddress object.""" + raise NotImplementedError + + +class SolarwindsIPAddressToInterface(IPAddressToInterfaceModel): + """Solarwinds implementation of IPAddressToInterface DiffSync model.""" + + @classmethod + def create(cls, adapter, ids, attrs): + """Create IPAddressToInterface in Solarwinds from SolarwindsIPAddressToInterface object.""" + raise NotImplementedError + + def update(self, attrs): + """Update IPAddressToInterface in Solarwinds from SolarwindsIPAddressToInterface object.""" + raise NotImplementedError + + def delete(self): + """Delete IPAddressToInterface in Solarwinds from SolarwindsIPAddressToInterface object.""" + raise NotImplementedError diff --git a/nautobot_ssot/integrations/solarwinds/jobs.py b/nautobot_ssot/integrations/solarwinds/jobs.py new file mode 100644 index 000000000..8195beb6c --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/jobs.py @@ -0,0 +1,286 @@ +# pylint: disable=R0801 +"""Jobs for Solarwinds SSoT integration.""" + +from diffsync.enum import DiffSyncFlags +from django.urls import reverse +from nautobot.apps.jobs import BooleanVar, ChoiceVar, JSONVar, ObjectVar, StringVar, TextVar, register_jobs +from nautobot.dcim.models import Device, Location, LocationType +from nautobot.extras.choices import SecretsGroupAccessTypeChoices, SecretsGroupSecretTypeChoices +from nautobot.extras.models import ExternalIntegration, Role +from nautobot.tenancy.models import Tenant + +from nautobot_ssot.integrations.solarwinds.diffsync.adapters import nautobot, solarwinds +from nautobot_ssot.integrations.solarwinds.utils.solarwinds import SolarwindsClient +from nautobot_ssot.jobs.base import DataMapping, DataSource + +name = "Solarwinds SSoT" # pylint: disable=invalid-name + + +ROLE_CHOICES = (("DeviceType", "DeviceType"), ("Hostname", "Hostname")) +PULL_FROM_CHOICES = (("Containers", "Containers"), ("CustomProperty", "Custom Property")) + + +class JobConfigError(Exception): + """Custom Exception for misconfigured Job form.""" + + +class SolarwindsDataSource(DataSource): # pylint: disable=too-many-instance-attributes + """Solarwinds SSoT Data Source.""" + + integration = ObjectVar( + model=ExternalIntegration, + queryset=ExternalIntegration.objects.all(), + display_field="display", + label="Solarwinds Instance", + required=True, + ) + pull_from = ChoiceVar( + choices=PULL_FROM_CHOICES, + label="Pull Devices From:", + description="Specify whether to pull all devices from SolarWinds containers, or use a Custom Property", + required=True, + ) + custom_property = StringVar( + description="Name of SolarWinds Custom Property existing (set to True) on Devices to be synced.", + label="SolarWinds Custom Property", + required=False, + ) + location_override = ObjectVar( + model=Location, + queryset=Location.objects.all(), + description="Override using Container names for Location, all devices synced will be placed here.", + label="Location Override", + required=False, + ) + containers = TextVar( + default="ALL", + description="Comma separated list of Containers to be Imported. Use 'ALL' to import every container from Solarwinds. Must specify Top Container if `ALL` is specified, unless using CustomProperty.", + label="Container(s)", + required=True, + ) + top_container = TextVar( + default="", + description="Top-level Container if `ALL` containers are to be imported.", + label="Top Container", + required=False, + ) + location_type = ObjectVar( + model=LocationType, + queryset=LocationType.objects.all(), + description="LocationType to define Container(s) as. Must support Device ContentType.", + label="Location Type", + required=False, + ) + parent = ObjectVar( + model=Location, + queryset=Location.objects.all(), + description="Parent Location to assign created Containers to if specified LocationType requires parent be defined.", + label="Parent Location", + required=False, + ) + tenant = ObjectVar( + model=Tenant, + queryset=Tenant.objects.all(), + description="Tenant to assign to imported Devices.", + label="Tenant", + required=False, + ) + role_map = JSONVar( + label="Device Roles Map", description="Mapping of matching object to Role.", default={}, required=False + ) + role_choice = ChoiceVar( + choices=ROLE_CHOICES, + label="Role Map Matching Attribute", + description="Specify which Device attribute to match for Role Map.", + ) + default_role = ObjectVar( + label="Default Device Role", + model=Role, + queryset=Role.objects.all(), + query_params={"content_types": Device._meta.label_lower}, + display_field="name", + required=True, + ) + debug = BooleanVar(description="Enable for more verbose debug logging", default=False) + + def __init__(self): + """Initialize job objects.""" + super().__init__() + self.data = None + self.diffsync_flags = DiffSyncFlags.CONTINUE_ON_FAILURE + + class Meta: # pylint: disable=too-few-public-methods + """Meta data for Solarwinds.""" + + name = "Solarwinds to Nautobot" + data_source = "Solarwinds" + data_target = "Nautobot" + description = "Sync information from Solarwinds to Nautobot" + has_sensitive_variables = False + field_order = [ + "dryrun", + "debug", + "integration", + "location_type", + "pull_from", + "custom_property", + "containers", + "top_container", + "location_override", + "parent", + "tenant", + "default_role", + "role_choice", + "role_map", + ] + + @classmethod + def config_information(cls): + """Dictionary describing the configuration of this DataSource.""" + return {} + + @classmethod + def data_mappings(cls): + """List describing the data mappings involved in this DataSource.""" + return ( + DataMapping("Containers", None, "Locations", reverse("dcim:location_list")), + DataMapping("Devices", None, "Devices", reverse("dcim:device_list")), + DataMapping("Interfaces", None, "Interfaces", reverse("dcim:interface_list")), + DataMapping("Prefixes", None, "Prefixes", reverse("ipam:prefix_list")), + DataMapping("IP Addresses", None, "IP Addresses", reverse("ipam:ipaddress_list")), + DataMapping("Vendor", None, "Manufacturers", reverse("dcim:manufacturer_list")), + DataMapping("Model/DeviceType", None, "DeviceTypes", reverse("dcim:devicetype_list")), + DataMapping("Model/Vendor", None, "Platforms", reverse("dcim:platform_list")), + DataMapping("OS Version", None, "SoftwareVersions", reverse("dcim:softwareversion_list")), + ) + + def validate_containers(self): + """Confirm Job form variable for containers.""" + if self.containers == "": + self.logger.error("Containers variable must be defined with container name(s) or 'ALL'.") + raise JobConfigError + if self.pull_from == "Containers" and self.containers == "ALL" and self.top_container == "": + self.logger.error("Top Container must be specified if `ALL` Containers are to be imported.") + raise JobConfigError + + def validate_location_configuration(self): + """Confirm that LocationType or Location Override are set properly.""" + if not self.location_type: + if not self.location_override: + self.logger.error("A Location Type must be specified, unless using Location Override.") + raise JobConfigError + return + + if self.location_type.parent is not None and self.parent is None: + self.logger.error("LocationType %s requires Parent Location be specified.", self.location_type) + raise JobConfigError + if self.location_type.parent is None and self.parent: + self.logger.error( + "LocationType %s does not require a Parent location, but a Parent location was chosen.", + self.location_type, + ) + raise JobConfigError + + if ("dcim", "device") not in self.location_type.content_types.values_list("app_label", "model"): + self.logger.error( + "Specified LocationType %s is missing Device ContentType. Please change LocationType or add Device ContentType to %s LocationType and re-run Job.", + self.location_type, + self.location_type, + ) + raise JobConfigError + + def validate_role_map(self): + """Confirm configuration of Role Map Job var.""" + if self.role_map and not self.role_choice: + self.logger.error("Role Map Matching Attribute must be defined if Role Map is specified.") + raise JobConfigError + + def validate_custom_property(self): + """Confirm configuration of Custom Property var.""" + if self.pull_from == "CustomProperty" and not self.custom_property: + self.logger.error("Custom Property value must exist if pulling from Custom Property.") + raise JobConfigError + if self.pull_from == "CustomProperty" and not self.location_override: + self.logger.error("Location Override must be selected if pulling from CustomProperty.") + raise JobConfigError + + def load_source_adapter(self): + """Load data from Solarwinds into DiffSync models.""" + self.validate_containers() + self.validate_location_configuration() + self.validate_custom_property() + _sg = self.integration.secrets_group + username = _sg.get_secret_value( + access_type=SecretsGroupAccessTypeChoices.TYPE_HTTP, + secret_type=SecretsGroupSecretTypeChoices.TYPE_USERNAME, + ) + password = _sg.get_secret_value( + access_type=SecretsGroupAccessTypeChoices.TYPE_HTTP, + secret_type=SecretsGroupSecretTypeChoices.TYPE_PASSWORD, + ) + port = self.integration.extra_config.get("port") if self.integration.extra_config else None + retries = self.integration.extra_config.get("retries") if self.integration.extra_config else None + client = SolarwindsClient( + hostname=self.integration.remote_url, + username=username, + password=password, + port=port if port else 17774, + retries=retries if retries else 5, + timeout=self.integration.timeout, + verify=self.integration.verify_ssl, + job=self, + ) + self.source_adapter = solarwinds.SolarwindsAdapter( + job=self, + sync=self.sync, + client=client, + containers=self.containers, + location_type=self.location_type, + parent=self.parent, + tenant=self.tenant, + ) + self.source_adapter.load() + + def load_target_adapter(self): + """Load data from Nautobot into DiffSync models.""" + self.target_adapter = nautobot.NautobotAdapter(job=self, sync=self.sync) + self.target_adapter.load() + + def run( # pylint: disable=arguments-differ, too-many-arguments + self, + integration, + containers, + top_container, + dryrun, + location_type, + parent, + tenant, + role_map, + role_choice, + default_role, + memory_profiling, + debug, + *args, + **kwargs, + ): + """Perform data synchronization.""" + self.integration = integration + self.pull_from = kwargs["pull_from"] + self.custom_property = kwargs["custom_property"] + self.location_override = kwargs["location_override"] + self.containers = containers + self.top_container = top_container + self.location_type = location_type if location_type else self.location_override.location_type + self.parent = parent + self.tenant = tenant + self.role_map = role_map + self.role_choice = role_choice + self.default_role = default_role + self.debug = debug + self.dryrun = dryrun + self.memory_profiling = memory_profiling + super().run(dryrun=self.dryrun, memory_profiling=self.memory_profiling, *args, **kwargs) + + +jobs = [SolarwindsDataSource] +register_jobs(*jobs) diff --git a/nautobot_ssot/integrations/solarwinds/signals.py b/nautobot_ssot/integrations/solarwinds/signals.py new file mode 100644 index 000000000..9b91187c9 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/signals.py @@ -0,0 +1,46 @@ +# pylint: disable=R0801 +"""Signals triggered when Nautobot starts to perform certain actions.""" + +from nautobot.core.signals import nautobot_database_ready +from nautobot.extras.choices import CustomFieldTypeChoices + + +def register_signals(sender): + """Register signals for Solarwinds integration.""" + nautobot_database_ready.connect(nautobot_database_ready_callback, sender=sender) + + +def nautobot_database_ready_callback(sender, *, apps, **kwargs): # pylint: disable=unused-argument + """Adds OS Version and Physical Address CustomField to Devices and System of Record and Last Sync'd to Device, and IPAddress. + + Callback function triggered by the nautobot_database_ready signal when the Nautobot database is fully ready. + """ + # pylint: disable=invalid-name, too-many-locals + ContentType = apps.get_model("contenttypes", "ContentType") + CustomField = apps.get_model("extras", "CustomField") + Device = apps.get_model("dcim", "Device") + IPAddress = apps.get_model("ipam", "IPAddress") + Prefix = apps.get_model("ipam", "Prefix") + + snmp_loc_dict = { + "key": "snmp_location", + "type": CustomFieldTypeChoices.TYPE_TEXT, + "label": "SNMP Location", + } + snmp_loc_field, _ = CustomField.objects.get_or_create(key=snmp_loc_dict["key"], defaults=snmp_loc_dict) + snmp_loc_field.content_types.add(ContentType.objects.get_for_model(Device)) + sor_cf_dict = { + "type": CustomFieldTypeChoices.TYPE_TEXT, + "key": "system_of_record", + "label": "System of Record", + } + sor_custom_field, _ = CustomField.objects.update_or_create(key=sor_cf_dict["key"], defaults=sor_cf_dict) + sync_cf_dict = { + "type": CustomFieldTypeChoices.TYPE_DATE, + "key": "last_synced_from_sor", + "label": "Last sync from System of Record", + } + sync_custom_field, _ = CustomField.objects.update_or_create(key=sync_cf_dict["key"], defaults=sync_cf_dict) + for model in [Device, IPAddress, Prefix]: + sor_custom_field.content_types.add(ContentType.objects.get_for_model(model)) + sync_custom_field.content_types.add(ContentType.objects.get_for_model(model)) diff --git a/nautobot_ssot/integrations/solarwinds/utils/__init__.py b/nautobot_ssot/integrations/solarwinds/utils/__init__.py new file mode 100644 index 000000000..8ec3c051d --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions for working with Solarwinds and Nautobot.""" diff --git a/nautobot_ssot/integrations/solarwinds/utils/nautobot.py b/nautobot_ssot/integrations/solarwinds/utils/nautobot.py new file mode 100644 index 000000000..09a18a457 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/utils/nautobot.py @@ -0,0 +1 @@ +"""Utility functions for working with Nautobot.""" diff --git a/nautobot_ssot/integrations/solarwinds/utils/solarwinds.py b/nautobot_ssot/integrations/solarwinds/utils/solarwinds.py new file mode 100644 index 000000000..0ff05a733 --- /dev/null +++ b/nautobot_ssot/integrations/solarwinds/utils/solarwinds.py @@ -0,0 +1,564 @@ +"""Utility functions for working with Solarwinds.""" + +import json +import re +from collections import defaultdict +from datetime import datetime +from typing import Dict, List, Optional + +import requests +import urllib3 +from netutils.bandwidth import bits_to_name +from netutils.interface import split_interface +from netutils.ip import is_netmask, netmask_to_cidr +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from nautobot_ssot.integrations.solarwinds.constants import ETH_INTERFACE_NAME_MAP, ETH_INTERFACE_SPEED_MAP + + +class SolarwindsClient: # pylint: disable=too-many-public-methods, too-many-instance-attributes + """Class for handling communication to Solarwinds.""" + + def __init__( # pylint: disable=too-many-arguments + self, + hostname: str, + username: str, + password: str, + port: int = 17774, + verify: bool = False, + session: requests.Session = None, + **kwargs, + ): + """Initialize shared variables for Solarwinds client. + + Args: + hostname (str): Hostname of the SolarWinds server to connect to + username (str): Username to authenticate with + password (str): Password to authenticate with + port (int, optional): Port on the remote server to connect to (17778=Legacy, 17774=preferred). Defaults to 17774. + verify (bool, optional): Validate the SSL Certificate when using Requests. Defaults to False. + session (requests.Session, optional): Customized requests session to use. Defaults to None. + kwargs (dict): Keyword arguments to catch unspecified keyword arguments. + """ + self.url = f"{hostname}:{port}/SolarWinds/InformationService/v3/Json/" + self._session = session or requests.Session() + self._session.auth = (username, password) + self._session.headers.update({"Content-Type": "application/json"}) + self._session.verify = verify + if not verify: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self.job = kwargs.pop("job", None) + self.batch_size = ( + self.job.integration.extra_config.get("batch_size", 100) if self.job.integration.extra_config else 100 + ) + + # Set up retries + self.timeout = kwargs.pop("timeout", None) + self.retries = kwargs.pop("retries", None) + if self.retries is not None: + retry_strategy = Retry( + total=self.retries, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=[ + "HEAD", + "GET", + "PUT", + "DELETE", + "OPTIONS", + "TRACE", + "POST", + ], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self._session.mount("https://", adapter) + self._session.mount("http://", adapter) + + def query(self, query: str, **params): + """Perform a query against the SolarWinds SWIS API. + + Args: + query (str): SWQL query to execute + params (dict, optional): Parameters to pass to the query. Defaults to {}. + + Returns: + dict: JSON response from the SWIS API + """ + return self._req("POST", "Query", {"query": query, "parameters": params}).json() + + @staticmethod + def _json_serial(obj): # pylint: disable=inconsistent-return-statements + """JSON serializer for objects not serializable by default json code.""" + if isinstance(obj, datetime): + serial = obj.isoformat() + return serial + + def _req(self, method: str, frag: str, data: Optional[dict] = None) -> requests.Response: + """Perform the actual request to the SolarWinds SWIS API. + + Args: + method (str): HTTP method to use + frag (str): URL fragment to append to the base URL + data (dict, optional): Data payload to include in the request. Defaults to {}. + + Returns: + requests.Response: Response object from the request + """ + try: + resp = self._session.request( + method, + self.url + frag, + data=json.dumps(data, default=self._json_serial), + timeout=self.timeout, + ) + + # try to extract reason from response when request returns error + if 400 <= resp.status_code < 600: + try: + resp.reason = json.loads(resp.text)["Message"] + except json.decoder.JSONDecodeError: + pass + + resp.raise_for_status() + return resp + except requests.exceptions.RequestException as err: + self.job.logger.error(f"An error occurred: {err}") + # Return an empty response object to avoid breaking the calling code + return requests.Response() + + def get_filtered_container_ids(self, containers: str) -> Dict[str, int]: + """Get a list of container IDs from Solarwinds. + + Args: + containers (str): Comma-separated list of container names to get IDs for. + + Returns: + Dict[str, int]: Dictionary of container names to IDs. + """ + container_ids = {} + for container in containers.split(","): + container_id = self.find_container_id_by_name(container_name=container) + if container_id != -1: + container_ids[container] = container_id + else: + self.job.logger.error(f"Unable to find container {container}.") + return container_ids + + def get_nodes_custom_property(self, custom_property: str) -> Dict[str, List[dict]]: + """Get all node IDs for all nodes based on SolarWinds CustomProperty. + + Args: + container_ids (Dict[str, int]): Dictionary of container names to their ID. + custom_property (str): SolarWinds CustomProperty which must be True for Nautobot to pull in. + + Returns: + Dict[str, List[dict]]: Dictionary of container names to list of node IDs in that container. + """ + query = f"SELECT DISTINCT SysName AS Name, MemberPrimaryID FROM Orion.Nodes INNER JOIN Orion.ContainerMembers ON Nodes.NodeID = ContainerMembers.MemberPrimaryID WHERE Nodes.CustomProperties.{custom_property}='True'" # noqa: S608 + nodes = self.query(query) + + container_nodes = nodes["results"] + return container_nodes + + def get_container_nodes( + self, container_ids: Dict[str, int], custom_property: Optional[str] = None + ) -> Dict[str, List[dict]]: + """Get node IDs for all nodes in specified container ID. + + Args: + container_ids (Dict[str, int]): Dictionary of container names to their ID. + custom_property (str): Optional SolarWinds CustomProperty which must be True for Nautobot to pull in. + + Returns: + Dict[str, List[dict]]: Dictionary of container names to list of node IDs in that container. + """ + container_nodes = {} + for container_name, container_id in container_ids.items(): + self.job.logger.debug(f"Gathering container nodes for {container_name} CID: {container_id}.") + container_nodes[container_name] = self.recurse_collect_container_nodes( + current_container_id=container_id, custom_property=custom_property + ) + return container_nodes + + def get_top_level_containers(self, top_container: str) -> Dict[str, int]: + """Retrieve all containers from Solarwinds. + + Returns: + Dict[str, int]: Dictionary of container names to IDs. + """ + top_container_id = self.find_container_id_by_name(container_name=top_container) + query = f"SELECT ContainerID, Name, MemberPrimaryID FROM Orion.ContainerMembers WHERE ContainerID = '{top_container_id}'" # noqa: S608 + results = self.query(query)["results"] + return {x["Name"]: x["MemberPrimaryID"] for x in results} + + def recurse_collect_container_nodes(self, current_container_id: int, custom_property: Optional[str] = None) -> list: + """Recursively gather all nodes for specified container ID. + + Args: + current_container_id (int): Container ID to retrieve nodes for. + custom_property (str): Optional SolarWinds CustomProperty which must be True for Nautobot to pull in. + + Returns: + list: List of node IDs in specified container. + """ + nodes_list = [] + if custom_property: + query = f"SELECT ContainerID, SysName AS Name, MemberEntityType, MemberPrimaryID FROM Orion.Nodes INNER JOIN Orion.ContainerMembers ON Nodes.NodeID = ContainerMembers.MemberPrimaryID WHERE Nodes.CustomProperties.{custom_property}='True' AND ContainerID = '{current_container_id}'" # noqa: S608 + else: + query = f"SELECT ContainerID, Name, MemberEntityType, MemberPrimaryID FROM Orion.ContainerMembers WHERE ContainerID = '{current_container_id}'" # noqa: S608 + container_members = self.query(query) + if container_members["results"]: + for member in container_members["results"]: + if member["MemberEntityType"] == "Orion.Groups": + self.job.logger.debug(f"Exploring container: {member['Name']} CID: {member['MemberPrimaryID']}") + nodes_list.extend(self.recurse_collect_container_nodes(member["MemberPrimaryID"])) + elif member["MemberEntityType"] == "Orion.Nodes": + nodes_list.append(member) + return nodes_list + + def find_container_id_by_name(self, container_name: str) -> int: + """Find container ID by name in Solarwinds. + + Args: + container_name (str): Name of container to be found. + + Returns: + int: ID for specified container. Returns -1 if not found. + """ + query_results = self.query( + f"SELECT ContainerID FROM Orion.Container WHERE Name = '{container_name}'" # noqa: S608 + ) + if query_results["results"]: + return query_results["results"][0]["ContainerID"] + return -1 + + def build_node_details(self, nodes: List[dict]) -> Dict[int, dict]: + """Build dictionary of node information. + + Args: + nodes (List[dict]): List of node information dictionaries. + + Returns: + Dict[int, dict]: Dictionary of node information with key being node primaryID. + """ + node_details = defaultdict(dict) + for node in nodes: + node_details[node["MemberPrimaryID"]] = {"NodeHostname": node["Name"], "NodeID": node["MemberPrimaryID"]} + self.batch_fill_node_details(node_data=nodes, node_details=node_details, nodes_per_batch=self.batch_size) + self.get_node_prefix_length(node_data=nodes, node_details=node_details, nodes_per_batch=self.batch_size) + self.job.logger.info("Loading interface details for nodes.") + self.gather_interface_data(node_data=nodes, node_details=node_details, nodes_per_batch=self.batch_size) + self.gather_ipaddress_data(node_data=nodes, node_details=node_details, nodes_per_batch=self.batch_size) + return node_details + + def batch_fill_node_details(self, node_data: list, node_details: dict, nodes_per_batch: int): + """Retrieve details from Solarwinds about specified nodes. + + Args: + node_data (list): List of nodes in containers. + node_details (dict): Dictionary of node details. + nodes_per_batch (int): Number of nodes to be processed per batch. + """ + current_idx = 0 + current_batch = 1 + total_batches = ( + len(node_data) // nodes_per_batch + if len(node_data) % nodes_per_batch == 0 + else len(node_data) // nodes_per_batch + 1 + ) + + while current_idx < len(node_data): + batch_nodes = node_data[current_idx : current_idx + nodes_per_batch] # noqa E203 + current_idx += nodes_per_batch + # Get the node details + if self.job.debug: + self.job.logger.debug(f"Processing batch {current_batch} of {total_batches} - Orion.Nodes.") + details_query = """ + SELECT IOSVersion AS Version, + o.IPAddress, + Location AS SNMPLocation, + o.Vendor, + MachineType AS DeviceType, + h.Model, + h.ServiceTag, + o.NodeID + FROM Orion.Nodes o LEFT JOIN Orion.HardwareHealth.HardwareInfo h ON o.NodeID = h.NodeID + WHERE NodeID IN ( + """ + for idx, node in enumerate(batch_nodes): + details_query += f"'{node['MemberPrimaryID']}'" + if idx < len(batch_nodes) - 1: + details_query += "," + details_query += ")" + query_results = self.query(details_query) + if not query_results["results"]: + if self.job.debug: + self.job.logger.error("Error: No node details found for the batch of nodes") + continue + + for result in query_results["results"]: + if result["NodeID"] in node_details: + node_id = result["NodeID"] + node_details[node_id]["Version"] = result["Version"] + node_details[node_id]["IPAddress"] = result["IPAddress"] + node_details[node_id]["SNMPLocation"] = result["SNMPLocation"] + node_details[node_id]["Vendor"] = result["Vendor"] + node_details[node_id]["DeviceType"] = result["DeviceType"] + node_details[node_id]["Model"] = result["Model"] + node_details[node_id]["ServiceTag"] = result["ServiceTag"] + # making prefix length default of 32 and will updated to the correct value in subsequent query. + node_details[node_id]["PFLength"] = 128 if ":" in result["IPAddress"] else 32 + current_batch += 1 + + def get_node_prefix_length(self, node_data: list, node_details: dict, nodes_per_batch: int): + """Gather node prefix length from IPAM.IPInfo if available. + + Args: + node_data (list): List of nodes in containers. + node_details (dict): Dictionary of node details. + nodes_per_batch (int): Number of nodes to be processed per batch. + """ + current_idx = 0 + current_batch = 1 + total_batches = ( + len(node_data) // nodes_per_batch + if len(node_data) % nodes_per_batch == 0 + else len(node_data) // nodes_per_batch + 1 + ) + + while current_idx < len(node_data): + batch_nodes = node_data[current_idx : current_idx + nodes_per_batch] # noqa E203 + current_idx += nodes_per_batch + # Get the node details + if self.job.debug: + self.job.logger.debug(f"Processing batch {current_batch} of {total_batches} - IPAM.IPInfo.") + + query = "SELECT i.CIDR AS PFLength, o.NodeID FROM Orion.Nodes o JOIN IPAM.IPInfo i ON o.IPAddressGUID = i.IPAddressN WHERE o.NodeID IN (" + for idx, node in enumerate(batch_nodes): + query += f"'{node['MemberPrimaryID']}'" + if idx < len(batch_nodes) - 1: + query += "," + query += ")" + query_results = self.query(query) + if not query_results["results"]: + if self.job.debug: + self.job.logger.error("Error: No node details found for the batch of nodes") + continue + + for result in query_results["results"]: + if result["NodeID"] in node_details: + node_details[result["NodeID"]]["PFLength"] = result["PFLength"] + current_batch += 1 + + def gather_interface_data(self, node_data: list, node_details: dict, nodes_per_batch: int): + """Retrieve interface details from Solarwinds about specified nodes. + + Args: + node_data (list): List of nodes in containers. + node_details (dict): Dictionary of node details. + nodes_per_batch (int): Number of nodes to be processed per batch. + """ + current_idx = 0 + current_batch = 1 + while current_idx < len(node_data): + batch_nodes = node_data[current_idx : current_idx + nodes_per_batch] # noqa E203 + current_idx += nodes_per_batch + query = """ + SELECT n.NodeID, + sa.StatusName AS Enabled, + so.StatusName AS Status, + i.Name, + i.MAC, + i.Speed, + i.TypeName, + i.MTU + FROM Orion.Nodes n JOIN Orion.NPM.Interfaces i ON n.NodeID = i.NodeID INNER JOIN Orion.StatusInfo sa ON i.AdminStatus = sa.StatusId INNER JOIN Orion.StatusInfo so ON i.OperStatus = so.StatusId + WHERE n.NodeID IN ( + """ + for idx, node in enumerate(batch_nodes): + query += f"'{node['MemberPrimaryID']}'" + if idx < len(batch_nodes) - 1: + query += "," + query += ")" + query_results = self.query(query) + if not query_results["results"]: + self.job.logger.error("Error: No node details found for the batch of nodes") + continue + + for result in query_results["results"]: + if result["NodeID"] in node_details: + node_id = result["NodeID"] + intf_id = result["Name"] + if not node_details[node_id].get("interfaces"): + node_details[node_id]["interfaces"] = {} + if intf_id not in node_details[node_id]["interfaces"]: + node_details[node_id]["interfaces"][intf_id] = {} + node_details[node_id]["interfaces"][intf_id]["Name"] = result["Name"] + node_details[node_id]["interfaces"][intf_id]["Enabled"] = result["Enabled"] + node_details[node_id]["interfaces"][intf_id]["Status"] = result["Status"] + node_details[node_id]["interfaces"][intf_id]["TypeName"] = result["TypeName"] + node_details[node_id]["interfaces"][intf_id]["Speed"] = result["Speed"] + node_details[node_id]["interfaces"][intf_id]["MAC"] = result["MAC"] + node_details[node_id]["interfaces"][intf_id]["MTU"] = result["MTU"] + current_batch += 1 + + @staticmethod + def standardize_device_type(node: dict) -> str: + """Method of choosing DeviceType from various potential locations and standardizing the result. + + Args: + node (dict): Node details with DeviceType and Model along with Vendor. + + Returns: + str: Standardized and sanitized string of DeviceType. + """ + device_type = "" + if node.get("Vendor"): + if node.get("Model"): + device_type = node["Model"].strip() + if not device_type.strip() and node.get("DeviceType"): + device_type = node["DeviceType"].strip() + if not device_type.strip(): + return "" + + if "Aruba" in node["Vendor"]: + device_type = device_type.replace("Aruba ", "").strip() + elif "Cisco" in node["Vendor"]: + device_type = device_type.replace("Cisco", "").strip() + device_type = device_type.replace("Catalyst ", "C").strip() + if ( + device_type + and "WS-" not in device_type + and "WLC" not in device_type + and "ASR" not in device_type + and not device_type.startswith("N") + ): + device_type = f"WS-{device_type}" + elif "Palo" in node["Vendor"]: + pass # Nothing needed yet. + return device_type + + def determine_interface_type(self, interface: dict) -> str: + """Determine interface type from a combination of Interface name, speed, and TypeName. + + Args: + interface (dict): Dictionary of Interface data to use to determine type. + + Returns: + str: Interface type based upon Interface name, speed, and TypeName. + """ + intf_default = "virtual" + if interface.get("TypeName") == "ethernetCsmacd": + intf_name = split_interface(interface=interface["Name"])[0] + if intf_name in ETH_INTERFACE_NAME_MAP: + return ETH_INTERFACE_NAME_MAP[intf_name] + intf_speed = bits_to_name(int(interface["Speed"])) + if intf_speed in ETH_INTERFACE_SPEED_MAP: + return ETH_INTERFACE_SPEED_MAP[intf_speed] + if intf_name == "Ethernet": + return ETH_INTERFACE_NAME_MAP["GigabitEthernet"] + if self.job.debug: + self.job.logger.debug(f"Unable to find Ethernet interface in map: {intf_name}") + return intf_default + + @staticmethod + def extract_version(version: str) -> str: + """Extract Device software version from string. + + Args: + version (str): Version string from Solarwinds. + + Returns: + str: Extracted version string. + """ + # Match on versions that have paranthesizes in string + sanitized_version = re.sub(pattern=r",?\s[Copyright,RELEASE].*", repl="", string=version) + return sanitized_version + + def gather_ipaddress_data(self, node_data: list, node_details: dict, nodes_per_batch: int): + """Retrieve IPAddress details from Solarwinds about specified nodes. + + Args: + node_data (list): List of nodes in containers. + node_details (dict): Dictionary of node details. + nodes_per_batch (int): Number of nodes to be processed per batch. + """ + current_idx = 0 + current_batch = 1 + while current_idx < len(node_data): + batch_nodes = node_data[current_idx : current_idx + nodes_per_batch] # noqa E203 + current_idx += nodes_per_batch + query = """ + SELECT NIPA.NodeID, + NIPA.InterfaceIndex, + NIPA.IPAddress, + NIPA.IPAddressType, + NPMI.Name, + NIPA.SubnetMask + FROM Orion.NodeIPAddresses NIPA INNER JOIN Orion.NPM.Interfaces NPMI ON NIPA.NodeID=NPMI.NodeID AND NIPA.InterfaceIndex=NPMI.InterfaceIndex INNER JOIN Orion.Nodes N ON NIPA.NodeID=N.NodeID + WHERE NIPA.NodeID IN ( + """ + for idx, node in enumerate(batch_nodes): + query += f"'{node['MemberPrimaryID']}'" + if idx < len(batch_nodes) - 1: + query += "," + query += ")" + query_results = self.query(query) + if not query_results["results"]: + self.job.logger.error("Error: No node details found for the batch of nodes") + continue + + for result in query_results["results"]: + if result["NodeID"] in node_details: + node_id = result["NodeID"] + ip_id = result["IPAddress"] + if is_netmask(result["SubnetMask"]): + netmask_cidr = netmask_to_cidr(netmask=result["SubnetMask"]) + else: + if ":" in result["IPAddress"]: + netmask_cidr = 128 + else: + netmask_cidr = 32 + if not node_details[node_id].get("ipaddrs"): + node_details[node_id]["ipaddrs"] = {} + if ip_id not in node_details[node_id]["ipaddrs"]: + node_details[node_id]["ipaddrs"][ip_id] = {} + node_details[node_id]["ipaddrs"][ip_id]["IPAddress"] = result["IPAddress"] + node_details[node_id]["ipaddrs"][ip_id]["SubnetMask"] = netmask_cidr + node_details[node_id]["ipaddrs"][ip_id]["IPAddressType"] = result["IPAddressType"] + node_details[node_id]["ipaddrs"][ip_id]["IntfName"] = result["Name"] + current_batch += 1 + + +def determine_role_from_devicetype(device_type: str, role_map: dict) -> str: + """Determine Device Role from passed DeviceType. + + Args: + device_type (str): DeviceType model to determine Device Role. + role_map (dict): Dictionary mapping DeviceType model to Device Role name. + + Returns: + str: Device Role name if match found else blank string. + """ + role = "" + if device_type in role_map: + return role_map[device_type] + return role + + +def determine_role_from_hostname(hostname: str, role_map: dict) -> str: + """Determine Device Role from passed Hostname. + + Args: + hostname (str): Device hostname to determine Device Role. + role_map (dict): Dictionary mapping regex patterns for Device hostnames to Device Role name. + + Returns: + str: Device Role name if match found else blank string. + """ + role = "" + for pattern, role_name in role_map.items(): + if re.match(pattern, hostname): + return role_name + return role diff --git a/nautobot_ssot/jobs/__init__.py b/nautobot_ssot/jobs/__init__.py index 8da1b6b46..13346adbf 100644 --- a/nautobot_ssot/jobs/__init__.py +++ b/nautobot_ssot/jobs/__init__.py @@ -20,6 +20,7 @@ "nautobot_ssot_aci": "2.2", "nautobot_ssot_dna_center": "2.2", "nautobot_ssot_meraki": "2.2", + "nautobot_ssot_solarwinds": "2.2", } diff --git a/nautobot_ssot/template_content.py b/nautobot_ssot/template_content.py index 6f3a4e317..eaea11db5 100644 --- a/nautobot_ssot/template_content.py +++ b/nautobot_ssot/template_content.py @@ -1,14 +1,14 @@ """App template content extensions of base Nautobot views.""" from django.urls import reverse -from nautobot.extras.plugins import PluginTemplateExtension +from nautobot.extras.plugins import TemplateExtension from nautobot_ssot.models import Sync # pylint: disable=abstract-method -class JobResultSyncLink(PluginTemplateExtension): +class JobResultSyncLink(TemplateExtension): """Add button linking to Sync data for relevant JobResults.""" model = "extras.jobresult" diff --git a/nautobot_ssot/tests/dna_center/fixtures.py b/nautobot_ssot/tests/dna_center/fixtures.py index 3aefc978e..4513e96fc 100644 --- a/nautobot_ssot/tests/dna_center/fixtures.py +++ b/nautobot_ssot/tests/dna_center/fixtures.py @@ -10,10 +10,15 @@ def load_json(path): LOCATION_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_locations.json") +LOCATION_WO_GLOBAL_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_locations_wo_global.json") +EXPECTED_BUILDING_MAP = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/expected_building_map.json") EXPECTED_DNAC_LOCATION_MAP = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map.json") EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL = load_json( path="./nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_wo_global.json" ) +EXPECTED_DNAC_LOCATION_MAP_W_JOB_LOCATION_MAP = load_json( + path="./nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_w_job_location_map.json" +) RECV_LOCATION_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_locations_recv.json") DEVICE_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_devices.json") RECV_DEVICE_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_devices_recv.json") @@ -22,7 +27,4 @@ def load_json(path): PORT_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_port_info.json") RECV_PORT_FIXTURE = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/get_port_info_recv.json") -EXPECTED_AREAS = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/expected_areas.json") -EXPECTED_AREAS_WO_GLOBAL = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/expected_areas_wo_global.json") -EXPECTED_BUILDINGS = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/expected_buildings.json") EXPECTED_FLOORS = load_json(path="./nautobot_ssot/tests/dna_center/fixtures/expected_floors.json") diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_areas.json b/nautobot_ssot/tests/dna_center/fixtures/expected_areas.json deleted file mode 100644 index f607c6e65..000000000 --- a/nautobot_ssot/tests/dna_center/fixtures/expected_areas.json +++ /dev/null @@ -1,299 +0,0 @@ -[ - { - "additionalInfo": [], - "name": "Global", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "siteNameHierarchy": "Global" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "type": "area" - } - } - ], - "name": "SanJose", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "siteNameHierarchy": "Global/SanJose" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", - "type": "area" - } - } - ], - "name": "OZ", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", - "siteNameHierarchy": "Global/OZ" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "type": "area" - } - } - ], - "name": "SanDiego", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "siteNameHierarchy": "Global/SanDiego" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "73b2f82c-413e-439e-a614-0ab0d0378114", - "type": "area" - } - } - ], - "name": "Antartica2", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "73b2f82c-413e-439e-a614-0ab0d0378114", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/73b2f82c-413e-439e-a614-0ab0d0378114", - "siteNameHierarchy": "Global/Antartica2" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "4e84a340-efdb-4f06-878f-3235173036ef", - "type": "area" - } - } - ], - "name": "Antartica3", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "4e84a340-efdb-4f06-878f-3235173036ef", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/4e84a340-efdb-4f06-878f-3235173036ef", - "siteNameHierarchy": "Global/Antartica3" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "20d34f71-f4db-4833-90f7-4208a349f876", - "type": "area" - } - } - ], - "name": "Antartica", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "20d34f71-f4db-4833-90f7-4208a349f876", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/20d34f71-f4db-4833-90f7-4208a349f876", - "siteNameHierarchy": "Global/Antartica" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "262696b1-aa87-432b-8a21-db9a77c51f23", - "type": "area" - } - } - ], - "name": "Australia", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "262696b1-aa87-432b-8a21-db9a77c51f23", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/262696b1-aa87-432b-8a21-db9a77c51f23", - "siteNameHierarchy": "Global/Australia" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "63a7fb03-d4b2-408e-a6ab-b4df0a198643", - "type": "area" - } - } - ], - "name": "Sydney", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "63a7fb03-d4b2-408e-a6ab-b4df0a198643", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/63a7fb03-d4b2-408e-a6ab-b4df0a198643", - "siteNameHierarchy": "Global/Sydney" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "f5a19514-3c1e-4127-a2b5-2a64a963c934", - "type": "area" - } - } - ], - "name": "Area_52", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "f5a19514-3c1e-4127-a2b5-2a64a963c934", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f5a19514-3c1e-4127-a2b5-2a64a963c934", - "siteNameHierarchy": "Global/Area_52" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", - "type": "area" - } - } - ], - "name": "Area_51", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", - "siteNameHierarchy": "Global/Area_51" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", - "type": "area" - } - } - ], - "name": "Area_51a", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", - "siteNameHierarchy": "Global/Area_51a" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "type": "area" - } - } - ], - "name": "Area 51", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "siteNameHierarchy": "Global/Area 51" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "type": "area" - } - } - ], - "name": "Forschungszentrum", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "siteNameHierarchy": "Global/Forschungszentrum" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "257c51d3-971d-49f4-83f7-c9baf334865a", - "type": "area" - } - } - ], - "name": "Texas", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "257c51d3-971d-49f4-83f7-c9baf334865a", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a", - "siteNameHierarchy": "Global/Texas" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "type": "area" - } - } - ], - "name": "Maryland", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "siteNameHierarchy": "Global/Maryland" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "8004668a-eb96-47cc-b659-bcc9c04669ba", - "type": "area" - } - } - ], - "name": "Treton", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "8004668a-eb96-47cc-b659-bcc9c04669ba", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/8004668a-eb96-47cc-b659-bcc9c04669ba", - "siteNameHierarchy": "Global/Treton" - }, - { - "parentId": "262696b1-aa87-432b-8a21-db9a77c51f23", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "262696b1-aa87-432b-8a21-db9a77c51f23", - "type": "area" - } - } - ], - "name": "Sydney", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "6e404051-4c06-4dab-adaa-72c5eeac577b", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/262696b1-aa87-432b-8a21-db9a77c51f23/6e404051-4c06-4dab-adaa-72c5eeac577b", - "siteNameHierarchy": "Global/Australia/Sydney" - } -] \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_areas_wo_global.json b/nautobot_ssot/tests/dna_center/fixtures/expected_areas_wo_global.json deleted file mode 100644 index 996a73682..000000000 --- a/nautobot_ssot/tests/dna_center/fixtures/expected_areas_wo_global.json +++ /dev/null @@ -1,291 +0,0 @@ -[ - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "type": "area" - } - } - ], - "name": "SanJose", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "siteNameHierarchy": "Global/SanJose" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", - "type": "area" - } - } - ], - "name": "OZ", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", - "siteNameHierarchy": "Global/OZ" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "type": "area" - } - } - ], - "name": "SanDiego", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "siteNameHierarchy": "Global/SanDiego" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "73b2f82c-413e-439e-a614-0ab0d0378114", - "type": "area" - } - } - ], - "name": "Antartica2", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "73b2f82c-413e-439e-a614-0ab0d0378114", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/73b2f82c-413e-439e-a614-0ab0d0378114", - "siteNameHierarchy": "Global/Antartica2" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "4e84a340-efdb-4f06-878f-3235173036ef", - "type": "area" - } - } - ], - "name": "Antartica3", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "4e84a340-efdb-4f06-878f-3235173036ef", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/4e84a340-efdb-4f06-878f-3235173036ef", - "siteNameHierarchy": "Global/Antartica3" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "20d34f71-f4db-4833-90f7-4208a349f876", - "type": "area" - } - } - ], - "name": "Antartica", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "20d34f71-f4db-4833-90f7-4208a349f876", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/20d34f71-f4db-4833-90f7-4208a349f876", - "siteNameHierarchy": "Global/Antartica" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "262696b1-aa87-432b-8a21-db9a77c51f23", - "type": "area" - } - } - ], - "name": "Australia", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "262696b1-aa87-432b-8a21-db9a77c51f23", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/262696b1-aa87-432b-8a21-db9a77c51f23", - "siteNameHierarchy": "Global/Australia" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "63a7fb03-d4b2-408e-a6ab-b4df0a198643", - "type": "area" - } - } - ], - "name": "Sydney", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "63a7fb03-d4b2-408e-a6ab-b4df0a198643", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/63a7fb03-d4b2-408e-a6ab-b4df0a198643", - "siteNameHierarchy": "Global/Sydney" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "f5a19514-3c1e-4127-a2b5-2a64a963c934", - "type": "area" - } - } - ], - "name": "Area_52", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "f5a19514-3c1e-4127-a2b5-2a64a963c934", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f5a19514-3c1e-4127-a2b5-2a64a963c934", - "siteNameHierarchy": "Global/Area_52" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", - "type": "area" - } - } - ], - "name": "Area_51", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", - "siteNameHierarchy": "Global/Area_51" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", - "type": "area" - } - } - ], - "name": "Area_51a", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", - "siteNameHierarchy": "Global/Area_51a" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "type": "area" - } - } - ], - "name": "Area 51", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "siteNameHierarchy": "Global/Area 51" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "type": "area" - } - } - ], - "name": "Forschungszentrum", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "siteNameHierarchy": "Global/Forschungszentrum" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "257c51d3-971d-49f4-83f7-c9baf334865a", - "type": "area" - } - } - ], - "name": "Texas", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "257c51d3-971d-49f4-83f7-c9baf334865a", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a", - "siteNameHierarchy": "Global/Texas" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "type": "area" - } - } - ], - "name": "Maryland", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "siteNameHierarchy": "Global/Maryland" - }, - { - "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "8004668a-eb96-47cc-b659-bcc9c04669ba", - "type": "area" - } - } - ], - "name": "Treton", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "8004668a-eb96-47cc-b659-bcc9c04669ba", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/8004668a-eb96-47cc-b659-bcc9c04669ba", - "siteNameHierarchy": "Global/Treton" - }, - { - "parentId": "262696b1-aa87-432b-8a21-db9a77c51f23", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "addressInheritedFrom": "262696b1-aa87-432b-8a21-db9a77c51f23", - "type": "area" - } - } - ], - "name": "Sydney", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "6e404051-4c06-4dab-adaa-72c5eeac577b", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/262696b1-aa87-432b-8a21-db9a77c51f23/6e404051-4c06-4dab-adaa-72c5eeac577b", - "siteNameHierarchy": "Global/Australia/Sydney" - } -] \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_building_map.json b/nautobot_ssot/tests/dna_center/fixtures/expected_building_map.json new file mode 100644 index 000000000..d85ca2f7f --- /dev/null +++ b/nautobot_ssot/tests/dna_center/fixtures/expected_building_map.json @@ -0,0 +1,50 @@ +{ + "5c59e37a-f12d-4e84-a085-ac5c02f240d4": { + "name": "Building1", + "loc_type": "building", + "parent": "SanJose", + "parent_of_parent": null + }, + "6e1ebb51-62cd-400f-b130-a6959d81e775": { + "name": "VN_1", + "loc_type": "building", + "parent": "Texas", + "parent_of_parent": null + }, + "331f7e36-1a45-4c9f-bfa9-3425a053f81a": { + "name": "Deep Space", + "loc_type": "building", + "parent": "Area 51", + "parent_of_parent": null + }, + "398de1d9-d595-429d-8239-64b51d24f230": { + "name": "Texas_building", + "loc_type": "building", + "parent": "Texas", + "parent_of_parent": null + }, + "1c3bb089-2a74-4fdf-96e3-d1815ac67e38": { + "name": "Building 1", + "loc_type": "building", + "parent": "Forschungszentrum", + "parent_of_parent": null + }, + "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738": { + "name": "1", + "loc_type": "building", + "parent": "SanDiego", + "parent_of_parent": null + }, + "755faf69-0d07-48f7-b130-4806c32eb13e": { + "name": "secretlab", + "loc_type": "building", + "parent": "Maryland", + "parent_of_parent": null + }, + "fedd8d33-5334-413f-8a49-fb2459d7e337": { + "name": "Rome", + "loc_type": "building", + "parent": "Treton", + "parent_of_parent": null + } +} \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_buildings.json b/nautobot_ssot/tests/dna_center/fixtures/expected_buildings.json deleted file mode 100644 index fb3910f71..000000000 --- a/nautobot_ssot/tests/dna_center/fixtures/expected_buildings.json +++ /dev/null @@ -1,169 +0,0 @@ -[ - { - "parentId": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "United States", - "address": "1000 I Street, Sacramento, California 95814, United States", - "latitude": "38.581405819248886", - "addressInheritedFrom": "5c59e37a-f12d-4e84-a085-ac5c02f240d4", - "type": "building", - "longitude": "-121.49309067224416" - } - } - ], - "name": "Building1", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "5c59e37a-f12d-4e84-a085-ac5c02f240d4", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/eed5ccc3-d76f-46e2-9d8d-97624f5418ac/5c59e37a-f12d-4e84-a085-ac5c02f240d4", - "siteNameHierarchy": "Global/SanJose/Building1" - }, - { - "parentId": "257c51d3-971d-49f4-83f7-c9baf334865a", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "Vietnam", - "latitude": "38.10344327099361", - "addressInheritedFrom": "257c51d3-971d-49f4-83f7-c9baf334865a", - "type": "building", - "longitude": "-70.53258776522935" - } - } - ], - "name": "VN_1", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "6e1ebb51-62cd-400f-b130-a6959d81e775", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a/6e1ebb51-62cd-400f-b130-a6959d81e775", - "siteNameHierarchy": "Global/Texas/VN_1" - }, - { - "parentId": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "United States", - "address": "Mercury, NV 89023", - "latitude": "36.632621", - "addressInheritedFrom": "331f7e36-1a45-4c9f-bfa9-3425a053f81a", - "type": "building", - "longitude": "-115.934912" - } - } - ], - "name": "Deep Space", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "331f7e36-1a45-4c9f-bfa9-3425a053f81a", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/7760b82a-b07e-4fa7-8cad-91adfa12dd43/331f7e36-1a45-4c9f-bfa9-3425a053f81a", - "siteNameHierarchy": "Global/Area 51/Deep Space" - }, - { - "parentId": "257c51d3-971d-49f4-83f7-c9baf334865a", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "United States", - "address": "County Road 186, Brookesmith, Texas 76827, United States", - "latitude": "31.559432441659325", - "addressInheritedFrom": "398de1d9-d595-429d-8239-64b51d24f230", - "type": "building", - "longitude": "-99.19356001285279" - } - } - ], - "name": "Texas_building", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "398de1d9-d595-429d-8239-64b51d24f230", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a/398de1d9-d595-429d-8239-64b51d24f230", - "siteNameHierarchy": "Global/Texas/Texas_building" - }, - { - "parentId": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "Germany", - "address": "Wallstadter Straße 59, 68526 Ladenburg", - "latitude": "49.479617", - "addressInheritedFrom": "1c3bb089-2a74-4fdf-96e3-d1815ac67e38", - "type": "building", - "longitude": "8.602459" - } - } - ], - "name": "Building 1", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "1c3bb089-2a74-4fdf-96e3-d1815ac67e38", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/5c882916-4f65-45b5-a5cc-ca5fa927cba6/1c3bb089-2a74-4fdf-96e3-d1815ac67e38", - "siteNameHierarchy": "Global/Forschungszentrum/Building 1" - }, - { - "parentId": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "United States", - "address": "Ocean Drive Bar & Restaurant, 3915 Landis Ave, Sea Isle City, New Jersey 08243, United States", - "latitude": "39.156233", - "addressInheritedFrom": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", - "type": "building", - "longitude": "-74.690192" - } - } - ], - "name": "1", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2/2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", - "siteNameHierarchy": "Global/SanDiego/1" - }, - { - "parentId": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "United States", - "address": "123 Main Street", - "latitude": "39.0458", - "addressInheritedFrom": "755faf69-0d07-48f7-b130-4806c32eb13e", - "type": "building", - "longitude": "-76.6413" - } - } - ], - "name": "secretlab", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "755faf69-0d07-48f7-b130-4806c32eb13e", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f372dbbb-d689-4d9a-9a5e-887de33fbce5/755faf69-0d07-48f7-b130-4806c32eb13e", - "siteNameHierarchy": "Global/Maryland/secretlab" - }, - { - "parentId": "8004668a-eb96-47cc-b659-bcc9c04669ba", - "additionalInfo": [ - { - "nameSpace": "Location", - "attributes": { - "country": "United States", - "address": "51 West Center Road Southeast, Rome, Georgia 30161, United States", - "latitude": "34.237896413975506", - "addressInheritedFrom": "fedd8d33-5334-413f-8a49-fb2459d7e337", - "type": "building", - "longitude": "-85.13241190760431" - } - } - ], - "name": "Rome", - "instanceTenantId": "623f029857259506a56ad9bd", - "id": "fedd8d33-5334-413f-8a49-fb2459d7e337", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/8004668a-eb96-47cc-b659-bcc9c04669ba/fedd8d33-5334-413f-8a49-fb2459d7e337", - "siteNameHierarchy": "Global/Treton/Rome" - } -] \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map.json b/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map.json index cf93cadd9..90081b8a3 100644 --- a/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map.json +++ b/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map.json @@ -2,156 +2,187 @@ "9e5f9fc2-032e-45e8-994c-4a00629648e8": { "name": "Global", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "eed5ccc3-d76f-46e2-9d8d-97624f5418ac": { "name": "SanJose", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "5c59e37a-f12d-4e84-a085-ac5c02f240d4": { "name": "Building1", "loc_type": "building", - "parent": "SanJose" + "parent": "SanJose", + "parent_of_parent": "Global" }, "49aa97a7-5d45-4303-89dd-f76dfbfc624a": { "name": "Floor1", "loc_type": "floor", - "parent": "Building1" + "parent": "Building1", + "parent_of_parent": "SanJose" }, "925f1a03-05df-4d9a-b2e0-db1989367138": { "name": "1", "loc_type": "floor", - "parent": "1" + "parent": "1", + "parent_of_parent": "SanDiego" }, "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1": { "name": "OZ", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "04509ce4-6c88-40a3-b444-9e00f2cd97f2": { "name": "SanDiego", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "73b2f82c-413e-439e-a614-0ab0d0378114": { "name": "Antartica2", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "4e84a340-efdb-4f06-878f-3235173036ef": { "name": "Antartica3", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "6e1ebb51-62cd-400f-b130-a6959d81e775": { "name": "VN_1", "loc_type": "building", - "parent": "Texas" + "parent": "Texas", + "parent_of_parent": "Global" }, "20d34f71-f4db-4833-90f7-4208a349f876": { "name": "Antartica", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "262696b1-aa87-432b-8a21-db9a77c51f23": { "name": "Australia", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "6e404051-4c06-4dab-adaa-72c5eeac577b": { "name": "Sydney", "loc_type": "area", - "parent": "Australia" + "parent": "Australia", + "parent_of_parent": "Global" }, "63a7fb03-d4b2-408e-a6ab-b4df0a198643": { "name": "Sydney", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "f5a19514-3c1e-4127-a2b5-2a64a963c934": { "name": "Area_52", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee": { "name": "Area_51", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661": { "name": "Area_51a", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "7760b82a-b07e-4fa7-8cad-91adfa12dd43": { "name": "Area 51", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "331f7e36-1a45-4c9f-bfa9-3425a053f81a": { "name": "Deep Space", "loc_type": "building", - "parent": "Area 51" + "parent": "Area 51", + "parent_of_parent": "Global" }, "42a47e36-01ea-4189-8e54-16a2fd063648": { "name": "1st Floor", "loc_type": "floor", - "parent": "Deep Space" + "parent": "Deep Space", + "parent_of_parent": "Area 51" }, "398de1d9-d595-429d-8239-64b51d24f230": { "name": "Texas_building", "loc_type": "building", - "parent": "Texas" + "parent": "Texas", + "parent_of_parent": "Global" }, "5c882916-4f65-45b5-a5cc-ca5fa927cba6": { "name": "Forschungszentrum", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "1c3bb089-2a74-4fdf-96e3-d1815ac67e38": { "name": "Building 1", "loc_type": "building", - "parent": "Forschungszentrum" + "parent": "Forschungszentrum", + "parent_of_parent": "Global" }, "0b1f8f7f-f0be-4d14-974d-69b1693d39ec": { "name": "Lab", "loc_type": "floor", - "parent": "Building 1" + "parent": "Building 1", + "parent_of_parent": "Forschungszentrum" }, "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738": { "name": "1", "loc_type": "building", - "parent": "SanDiego" + "parent": "SanDiego", + "parent_of_parent": "Global" }, "257c51d3-971d-49f4-83f7-c9baf334865a": { "name": "Texas", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "f372dbbb-d689-4d9a-9a5e-887de33fbce5": { "name": "Maryland", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "8004668a-eb96-47cc-b659-bcc9c04669ba": { "name": "Treton", "loc_type": "area", - "parent": "Global" + "parent": "Global", + "parent_of_parent": null }, "755faf69-0d07-48f7-b130-4806c32eb13e": { "name": "secretlab", "loc_type": "building", - "parent": "Maryland" + "parent": "Maryland", + "parent_of_parent": "Global" }, "fedd8d33-5334-413f-8a49-fb2459d7e337": { "name": "Rome", "loc_type": "building", - "parent": "Treton" + "parent": "Treton", + "parent_of_parent": "Global" }, "f4ff8ca1-c062-4228-868a-d6cd0bc53852": { "name": "Floor 2", "loc_type": "floor", - "parent": "secretlab" + "parent": "secretlab", + "parent_of_parent": "Maryland" } } \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_w_job_location_map.json b/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_w_job_location_map.json new file mode 100644 index 000000000..416de3152 --- /dev/null +++ b/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_w_job_location_map.json @@ -0,0 +1,157 @@ +{ + "9e5f9fc2-032e-45e8-994c-4a00629648e8": { + "name": "Global", + "parent": null, + "parent_of_parent": null + }, + "eed5ccc3-d76f-46e2-9d8d-97624f5418ac": { + "name": "San Jose", + "parent": "California", + "parent_of_parent": "USA" + }, + "5c59e37a-f12d-4e84-a085-ac5c02f240d4": { + "name": "Building1", + "parent": "San Jose", + "parent_of_parent": null + }, + "49aa97a7-5d45-4303-89dd-f76dfbfc624a": { + "name": "Floor1", + "parent": "Building1", + "parent_of_parent": "San Jose" + }, + "925f1a03-05df-4d9a-b2e0-db1989367138": { + "name": "1", + "parent": "1", + "parent_of_parent": null + }, + "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1": { + "name": "OZ", + "parent": "Global", + "parent_of_parent": null + }, + "04509ce4-6c88-40a3-b444-9e00f2cd97f2": { + "name": "SanDiego", + "parent": "Global", + "parent_of_parent": null + }, + "73b2f82c-413e-439e-a614-0ab0d0378114": { + "name": "South Pole", + "parent": "Global", + "parent_of_parent": null + }, + "4e84a340-efdb-4f06-878f-3235173036ef": { + "name": "Antartica3", + "parent": "Global", + "parent_of_parent": null + }, + "6e1ebb51-62cd-400f-b130-a6959d81e775": { + "name": "VN_1", + "parent": "Texas", + "parent_of_parent": null + }, + "20d34f71-f4db-4833-90f7-4208a349f876": { + "name": "Antartica", + "parent": "Global", + "parent_of_parent": null + }, + "262696b1-aa87-432b-8a21-db9a77c51f23": { + "name": "Australia", + "parent": "Global", + "parent_of_parent": null + }, + "6e404051-4c06-4dab-adaa-72c5eeac577b": { + "name": "Sydney", + "parent": "Australia", + "parent_of_parent": null + }, + "63a7fb03-d4b2-408e-a6ab-b4df0a198643": { + "name": "Sydney", + "parent": "Global", + "parent_of_parent": null + }, + "f5a19514-3c1e-4127-a2b5-2a64a963c934": { + "name": "Area_52", + "parent": "Global", + "parent_of_parent": null + }, + "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee": { + "name": "Area_51", + "parent": "Global", + "parent_of_parent": null + }, + "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661": { + "name": "Area_51a", + "parent": "Global", + "parent_of_parent": null + }, + "7760b82a-b07e-4fa7-8cad-91adfa12dd43": { + "name": "Area 51", + "parent": "Global", + "parent_of_parent": null + }, + "331f7e36-1a45-4c9f-bfa9-3425a053f81a": { + "name": "Deep Space", + "parent": "Area 51", + "parent_of_parent": null + }, + "42a47e36-01ea-4189-8e54-16a2fd063648": { + "name": "1st Floor", + "parent": "Deep Space", + "parent_of_parent": null + }, + "398de1d9-d595-429d-8239-64b51d24f230": { + "name": "Texas_building", + "parent": "Texas", + "parent_of_parent": null + }, + "5c882916-4f65-45b5-a5cc-ca5fa927cba6": { + "name": "Forschungszentrum", + "parent": "Global", + "parent_of_parent": null + }, + "1c3bb089-2a74-4fdf-96e3-d1815ac67e38": { + "name": "Building 1", + "parent": "Forschungszentrum", + "parent_of_parent": null + }, + "0b1f8f7f-f0be-4d14-974d-69b1693d39ec": { + "name": "Lab", + "parent": "Building 1", + "parent_of_parent": null + }, + "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738": { + "name": "1", + "parent": "SanDiego", + "parent_of_parent": null + }, + "257c51d3-971d-49f4-83f7-c9baf334865a": { + "name": "Texas", + "parent": "Global", + "parent_of_parent": null + }, + "f372dbbb-d689-4d9a-9a5e-887de33fbce5": { + "name": "Maryland", + "parent": "Global", + "parent_of_parent": null + }, + "8004668a-eb96-47cc-b659-bcc9c04669ba": { + "name": "Treton", + "parent": "Global", + "parent_of_parent": null + }, + "755faf69-0d07-48f7-b130-4806c32eb13e": { + "name": "secretlab", + "parent": "Maryland", + "parent_of_parent": null + }, + "fedd8d33-5334-413f-8a49-fb2459d7e337": { + "name": "Rome", + "parent": "Treton", + "parent_of_parent": null + }, + "f4ff8ca1-c062-4228-868a-d6cd0bc53852": { + "name": "Floor 2", + "parent": "secretlab", + "parent_of_parent": null + } +} \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_wo_global.json b/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_wo_global.json index 235936b15..40d8be331 100644 --- a/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_wo_global.json +++ b/nautobot_ssot/tests/dna_center/fixtures/expected_dnac_location_map_wo_global.json @@ -2,151 +2,181 @@ "eed5ccc3-d76f-46e2-9d8d-97624f5418ac": { "name": "SanJose", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "5c59e37a-f12d-4e84-a085-ac5c02f240d4": { "name": "Building1", "loc_type": "building", - "parent": "SanJose" + "parent": "SanJose", + "parent_of_parent": null }, "49aa97a7-5d45-4303-89dd-f76dfbfc624a": { "name": "Floor1", "loc_type": "floor", - "parent": "Building1" + "parent": "Building1", + "parent_of_parent": "SanJose" }, "925f1a03-05df-4d9a-b2e0-db1989367138": { "name": "1", "loc_type": "floor", - "parent": "1" + "parent": "1", + "parent_of_parent": "SanDiego" }, "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1": { "name": "OZ", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "04509ce4-6c88-40a3-b444-9e00f2cd97f2": { "name": "SanDiego", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "73b2f82c-413e-439e-a614-0ab0d0378114": { "name": "Antartica2", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "4e84a340-efdb-4f06-878f-3235173036ef": { "name": "Antartica3", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "6e1ebb51-62cd-400f-b130-a6959d81e775": { "name": "VN_1", "loc_type": "building", - "parent": "Texas" + "parent": "Texas", + "parent_of_parent": null }, "20d34f71-f4db-4833-90f7-4208a349f876": { "name": "Antartica", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "262696b1-aa87-432b-8a21-db9a77c51f23": { "name": "Australia", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "6e404051-4c06-4dab-adaa-72c5eeac577b": { "name": "Sydney", "loc_type": "area", - "parent": "Australia" + "parent": "Australia", + "parent_of_parent": null }, "63a7fb03-d4b2-408e-a6ab-b4df0a198643": { "name": "Sydney", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "f5a19514-3c1e-4127-a2b5-2a64a963c934": { "name": "Area_52", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee": { "name": "Area_51", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661": { "name": "Area_51a", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "7760b82a-b07e-4fa7-8cad-91adfa12dd43": { "name": "Area 51", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "331f7e36-1a45-4c9f-bfa9-3425a053f81a": { "name": "Deep Space", "loc_type": "building", - "parent": "Area 51" + "parent": "Area 51", + "parent_of_parent": null }, "42a47e36-01ea-4189-8e54-16a2fd063648": { "name": "1st Floor", "loc_type": "floor", - "parent": "Deep Space" + "parent": "Deep Space", + "parent_of_parent": "Area 51" }, "398de1d9-d595-429d-8239-64b51d24f230": { "name": "Texas_building", "loc_type": "building", - "parent": "Texas" + "parent": "Texas", + "parent_of_parent": null }, "5c882916-4f65-45b5-a5cc-ca5fa927cba6": { "name": "Forschungszentrum", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "1c3bb089-2a74-4fdf-96e3-d1815ac67e38": { "name": "Building 1", "loc_type": "building", - "parent": "Forschungszentrum" + "parent": "Forschungszentrum", + "parent_of_parent": null }, "0b1f8f7f-f0be-4d14-974d-69b1693d39ec": { "name": "Lab", "loc_type": "floor", - "parent": "Building 1" + "parent": "Building 1", + "parent_of_parent": "Forschungszentrum" }, "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738": { "name": "1", "loc_type": "building", - "parent": "SanDiego" + "parent": "SanDiego", + "parent_of_parent": null }, "257c51d3-971d-49f4-83f7-c9baf334865a": { "name": "Texas", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "f372dbbb-d689-4d9a-9a5e-887de33fbce5": { "name": "Maryland", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "8004668a-eb96-47cc-b659-bcc9c04669ba": { "name": "Treton", "loc_type": "area", - "parent": null + "parent": null, + "parent_of_parent": null }, "755faf69-0d07-48f7-b130-4806c32eb13e": { "name": "secretlab", "loc_type": "building", - "parent": "Maryland" + "parent": "Maryland", + "parent_of_parent": null }, "fedd8d33-5334-413f-8a49-fb2459d7e337": { "name": "Rome", "loc_type": "building", - "parent": "Treton" + "parent": "Treton", + "parent_of_parent": null }, "f4ff8ca1-c062-4228-868a-d6cd0bc53852": { "name": "Floor 2", "loc_type": "floor", - "parent": "secretlab" + "parent": "secretlab", + "parent_of_parent": "Maryland" } } \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/fixtures/get_locations_wo_global.json b/nautobot_ssot/tests/dna_center/fixtures/get_locations_wo_global.json new file mode 100644 index 000000000..12a6a0bb6 --- /dev/null +++ b/nautobot_ssot/tests/dna_center/fixtures/get_locations_wo_global.json @@ -0,0 +1,639 @@ +[ + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", + "type": "area" + } + } + ], + "name": "SanJose", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/eed5ccc3-d76f-46e2-9d8d-97624f5418ac", + "siteNameHierarchy": "Global/SanJose" + }, + { + "parentId": "eed5ccc3-d76f-46e2-9d8d-97624f5418ac", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "1000 I Street, Sacramento, California 95814, United States", + "latitude": "38.581405819248886", + "addressInheritedFrom": "5c59e37a-f12d-4e84-a085-ac5c02f240d4", + "type": "building", + "longitude": "-121.49309067224416" + } + } + ], + "name": "Building1", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "5c59e37a-f12d-4e84-a085-ac5c02f240d4", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/eed5ccc3-d76f-46e2-9d8d-97624f5418ac/5c59e37a-f12d-4e84-a085-ac5c02f240d4", + "siteNameHierarchy": "Global/SanJose/Building1" + }, + { + "parentId": "5c59e37a-f12d-4e84-a085-ac5c02f240d4", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "address": "1000 I Street, Sacramento, California 95814, United States", + "addressInheritedFrom": "5c59e37a-f12d-4e84-a085-ac5c02f240d4", + "type": "floor" + } + }, + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "95095", + "floorIndex": "1" + } + }, + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "length": "100.0", + "width": "100.0", + "height": "10.0" + } + } + ], + "name": "Floor1", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "49aa97a7-5d45-4303-89dd-f76dfbfc624a", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/eed5ccc3-d76f-46e2-9d8d-97624f5418ac/5c59e37a-f12d-4e84-a085-ac5c02f240d4/49aa97a7-5d45-4303-89dd-f76dfbfc624a", + "siteNameHierarchy": "Global/SanJose/Building1/Floor1" + }, + { + "parentId": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "address": "Ocean Drive Bar & Restaurant, 3915 Landis Ave, Sea Isle City, New Jersey 08243, United States", + "addressInheritedFrom": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", + "type": "floor" + } + }, + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "95095", + "floorIndex": "1" + } + }, + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "length": "79.0", + "width": "100.0", + "height": "10.0" + } + } + ], + "name": "1", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "925f1a03-05df-4d9a-b2e0-db1989367138", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2/2d0d8545-b6de-4dda-a0f7-93bcd7d0e738/925f1a03-05df-4d9a-b2e0-db1989367138", + "siteNameHierarchy": "Global/SanDiego/1/1" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", + "type": "area" + } + } + ], + "name": "OZ", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/cf1746d3-4de0-4ef5-bd3a-a5e51191eee1", + "siteNameHierarchy": "Global/OZ" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", + "type": "area" + } + } + ], + "name": "SanDiego", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2", + "siteNameHierarchy": "Global/SanDiego" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "73b2f82c-413e-439e-a614-0ab0d0378114", + "type": "area" + } + } + ], + "name": "Antartica2", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "73b2f82c-413e-439e-a614-0ab0d0378114", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/73b2f82c-413e-439e-a614-0ab0d0378114", + "siteNameHierarchy": "Global/Antartica2" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "4e84a340-efdb-4f06-878f-3235173036ef", + "type": "area" + } + } + ], + "name": "Antartica3", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "4e84a340-efdb-4f06-878f-3235173036ef", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/4e84a340-efdb-4f06-878f-3235173036ef", + "siteNameHierarchy": "Global/Antartica3" + }, + { + "parentId": "257c51d3-971d-49f4-83f7-c9baf334865a", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "Vietnam", + "latitude": "38.10344327099361", + "addressInheritedFrom": "257c51d3-971d-49f4-83f7-c9baf334865a", + "type": "building", + "longitude": "-70.53258776522935" + } + } + ], + "name": "VN_1", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "6e1ebb51-62cd-400f-b130-a6959d81e775", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a/6e1ebb51-62cd-400f-b130-a6959d81e775", + "siteNameHierarchy": "Global/Texas/VN_1" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "20d34f71-f4db-4833-90f7-4208a349f876", + "type": "area" + } + } + ], + "name": "Antartica", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "20d34f71-f4db-4833-90f7-4208a349f876", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/20d34f71-f4db-4833-90f7-4208a349f876", + "siteNameHierarchy": "Global/Antartica" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "262696b1-aa87-432b-8a21-db9a77c51f23", + "type": "area" + } + } + ], + "name": "Australia", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "262696b1-aa87-432b-8a21-db9a77c51f23", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/262696b1-aa87-432b-8a21-db9a77c51f23", + "siteNameHierarchy": "Global/Australia" + }, + { + "parentId": "262696b1-aa87-432b-8a21-db9a77c51f23", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "262696b1-aa87-432b-8a21-db9a77c51f23", + "type": "area" + } + } + ], + "name": "Sydney", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "6e404051-4c06-4dab-adaa-72c5eeac577b", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/262696b1-aa87-432b-8a21-db9a77c51f23/6e404051-4c06-4dab-adaa-72c5eeac577b", + "siteNameHierarchy": "Global/Australia/Sydney" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "63a7fb03-d4b2-408e-a6ab-b4df0a198643", + "type": "area" + } + } + ], + "name": "Sydney", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "63a7fb03-d4b2-408e-a6ab-b4df0a198643", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/63a7fb03-d4b2-408e-a6ab-b4df0a198643", + "siteNameHierarchy": "Global/Sydney" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "f5a19514-3c1e-4127-a2b5-2a64a963c934", + "type": "area" + } + } + ], + "name": "Area_52", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "f5a19514-3c1e-4127-a2b5-2a64a963c934", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f5a19514-3c1e-4127-a2b5-2a64a963c934", + "siteNameHierarchy": "Global/Area_52" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", + "type": "area" + } + } + ], + "name": "Area_51", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/fa22b4e1-ee80-4aa8-8d76-83c4126d16ee", + "siteNameHierarchy": "Global/Area_51" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", + "type": "area" + } + } + ], + "name": "Area_51a", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f1c8b4ea-ccc1-4b26-9cfd-25c0eb4ca661", + "siteNameHierarchy": "Global/Area_51a" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", + "type": "area" + } + } + ], + "name": "Area 51", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/7760b82a-b07e-4fa7-8cad-91adfa12dd43", + "siteNameHierarchy": "Global/Area 51" + }, + { + "parentId": "7760b82a-b07e-4fa7-8cad-91adfa12dd43", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "Mercury, NV 89023", + "latitude": "36.632621", + "addressInheritedFrom": "331f7e36-1a45-4c9f-bfa9-3425a053f81a", + "type": "building", + "longitude": "-115.934912" + } + } + ], + "name": "Deep Space", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "331f7e36-1a45-4c9f-bfa9-3425a053f81a", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/7760b82a-b07e-4fa7-8cad-91adfa12dd43/331f7e36-1a45-4c9f-bfa9-3425a053f81a", + "siteNameHierarchy": "Global/Area 51/Deep Space" + }, + { + "parentId": "331f7e36-1a45-4c9f-bfa9-3425a053f81a", + "additionalInfo": [ + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "length": "300.0", + "width": "1000.0", + "height": "30.0" + } + }, + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "95098", + "imageURL": "", + "isCadFile": "false", + "floorIndex": "1" + } + }, + { + "nameSpace": "Location", + "attributes": { + "address": "Mercury, NV 89023", + "addressInheritedFrom": "331f7e36-1a45-4c9f-bfa9-3425a053f81a", + "type": "floor" + } + } + ], + "name": "1st Floor", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "42a47e36-01ea-4189-8e54-16a2fd063648", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/7760b82a-b07e-4fa7-8cad-91adfa12dd43/331f7e36-1a45-4c9f-bfa9-3425a053f81a/42a47e36-01ea-4189-8e54-16a2fd063648", + "siteNameHierarchy": "Global/Area 51/Deep Space/1st Floor" + }, + { + "parentId": "257c51d3-971d-49f4-83f7-c9baf334865a", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "County Road 186, Brookesmith, Texas 76827, United States", + "latitude": "31.559432441659325", + "addressInheritedFrom": "398de1d9-d595-429d-8239-64b51d24f230", + "type": "building", + "longitude": "-99.19356001285279" + } + } + ], + "name": "Texas_building", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "398de1d9-d595-429d-8239-64b51d24f230", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a/398de1d9-d595-429d-8239-64b51d24f230", + "siteNameHierarchy": "Global/Texas/Texas_building" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", + "type": "area" + } + } + ], + "name": "Forschungszentrum", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/5c882916-4f65-45b5-a5cc-ca5fa927cba6", + "siteNameHierarchy": "Global/Forschungszentrum" + }, + { + "parentId": "5c882916-4f65-45b5-a5cc-ca5fa927cba6", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "Germany", + "address": "Wallstadter Straße 59, 68526 Ladenburg", + "latitude": "49.479617", + "addressInheritedFrom": "1c3bb089-2a74-4fdf-96e3-d1815ac67e38", + "type": "building", + "longitude": "8.602459" + } + } + ], + "name": "Building 1", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "1c3bb089-2a74-4fdf-96e3-d1815ac67e38", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/5c882916-4f65-45b5-a5cc-ca5fa927cba6/1c3bb089-2a74-4fdf-96e3-d1815ac67e38", + "siteNameHierarchy": "Global/Forschungszentrum/Building 1" + }, + { + "parentId": "1c3bb089-2a74-4fdf-96e3-d1815ac67e38", + "additionalInfo": [ + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "length": "100.0", + "width": "100.0", + "height": "10.0" + } + }, + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "95095", + "imageURL": "", + "isCadFile": "false", + "floorIndex": "0" + } + }, + { + "nameSpace": "Location", + "attributes": { + "address": "Wallstadter Straße 59, 68526 Ladenburg", + "addressInheritedFrom": "1c3bb089-2a74-4fdf-96e3-d1815ac67e38", + "type": "floor" + } + } + ], + "name": "Lab", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "0b1f8f7f-f0be-4d14-974d-69b1693d39ec", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/5c882916-4f65-45b5-a5cc-ca5fa927cba6/1c3bb089-2a74-4fdf-96e3-d1815ac67e38/0b1f8f7f-f0be-4d14-974d-69b1693d39ec", + "siteNameHierarchy": "Global/Forschungszentrum/Building 1/Lab" + }, + { + "parentId": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "Ocean Drive Bar & Restaurant, 3915 Landis Ave, Sea Isle City, New Jersey 08243, United States", + "latitude": "39.156233", + "addressInheritedFrom": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", + "type": "building", + "longitude": "-74.690192" + } + } + ], + "name": "1", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2/2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", + "siteNameHierarchy": "Global/SanDiego/1" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "257c51d3-971d-49f4-83f7-c9baf334865a", + "type": "area" + } + } + ], + "name": "Texas", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "257c51d3-971d-49f4-83f7-c9baf334865a", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/257c51d3-971d-49f4-83f7-c9baf334865a", + "siteNameHierarchy": "Global/Texas" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", + "type": "area" + } + } + ], + "name": "Maryland", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f372dbbb-d689-4d9a-9a5e-887de33fbce5", + "siteNameHierarchy": "Global/Maryland" + }, + { + "parentId": "9e5f9fc2-032e-45e8-994c-4a00629648e8", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "8004668a-eb96-47cc-b659-bcc9c04669ba", + "type": "area" + } + } + ], + "name": "Treton", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "8004668a-eb96-47cc-b659-bcc9c04669ba", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/8004668a-eb96-47cc-b659-bcc9c04669ba", + "siteNameHierarchy": "Global/Treton" + }, + { + "parentId": "f372dbbb-d689-4d9a-9a5e-887de33fbce5", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "123 Main Street", + "latitude": "39.0458", + "addressInheritedFrom": "755faf69-0d07-48f7-b130-4806c32eb13e", + "type": "building", + "longitude": "-76.6413" + } + } + ], + "name": "secretlab", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "755faf69-0d07-48f7-b130-4806c32eb13e", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f372dbbb-d689-4d9a-9a5e-887de33fbce5/755faf69-0d07-48f7-b130-4806c32eb13e", + "siteNameHierarchy": "Global/Maryland/secretlab" + }, + { + "parentId": "8004668a-eb96-47cc-b659-bcc9c04669ba", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "51 West Center Road Southeast, Rome, Georgia 30161, United States", + "latitude": "34.237896413975506", + "addressInheritedFrom": "fedd8d33-5334-413f-8a49-fb2459d7e337", + "type": "building", + "longitude": "-85.13241190760431" + } + } + ], + "name": "Rome", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "fedd8d33-5334-413f-8a49-fb2459d7e337", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/8004668a-eb96-47cc-b659-bcc9c04669ba/fedd8d33-5334-413f-8a49-fb2459d7e337", + "siteNameHierarchy": "Global/Treton/Rome" + }, + { + "parentId": "755faf69-0d07-48f7-b130-4806c32eb13e", + "additionalInfo": [ + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "width": "50.0", + "length": "60.0", + "height": "10.0" + } + }, + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "95095", + "imageURL": "", + "isCadFile": "false", + "floorIndex": "1" + } + }, + { + "nameSpace": "Location", + "attributes": { + "address": "123 Main Street", + "addressInheritedFrom": "755faf69-0d07-48f7-b130-4806c32eb13e", + "type": "floor" + } + } + ], + "name": "Floor 2", + "instanceTenantId": "623f029857259506a56ad9bd", + "id": "f4ff8ca1-c062-4228-868a-d6cd0bc53852", + "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/f372dbbb-d689-4d9a-9a5e-887de33fbce5/755faf69-0d07-48f7-b130-4806c32eb13e/f4ff8ca1-c062-4228-868a-d6cd0bc53852", + "siteNameHierarchy": "Global/Maryland/secretlab/Floor 2" + } +] \ No newline at end of file diff --git a/nautobot_ssot/tests/dna_center/test_adapters_dna_center.py b/nautobot_ssot/tests/dna_center/test_adapters_dna_center.py index 22e859ca3..3d3131bbe 100644 --- a/nautobot_ssot/tests/dna_center/test_adapters_dna_center.py +++ b/nautobot_ssot/tests/dna_center/test_adapters_dna_center.py @@ -26,13 +26,13 @@ from nautobot_ssot.tests.dna_center.fixtures import ( DEVICE_DETAIL_FIXTURE, DEVICE_FIXTURE, - EXPECTED_AREAS, - EXPECTED_AREAS_WO_GLOBAL, - EXPECTED_BUILDINGS, + EXPECTED_BUILDING_MAP, EXPECTED_DNAC_LOCATION_MAP, + EXPECTED_DNAC_LOCATION_MAP_W_JOB_LOCATION_MAP, EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL, EXPECTED_FLOORS, LOCATION_FIXTURE, + LOCATION_WO_GLOBAL_FIXTURE, PORT_FIXTURE, ) @@ -141,219 +141,167 @@ def setUp(self): # pylint: disable=too-many-statements ) self.dna_center = DnaCenterAdapter(job=self.job, sync=None, client=self.dna_center_client, tenant=None) self.dna_center.dnac_location_map = EXPECTED_DNAC_LOCATION_MAP + self.dna_center.building_map = EXPECTED_BUILDING_MAP def test_build_dnac_location_map(self): """Test Nautobot adapter build_dnac_location_map method.""" self.dna_center.dnac_location_map = {} - actual = self.dna_center.build_dnac_location_map(locations=LOCATION_FIXTURE) + actual_floors = self.dna_center.build_dnac_location_map(locations=LOCATION_FIXTURE) expected = EXPECTED_DNAC_LOCATION_MAP - self.assertEqual(sorted(actual), sorted(expected)) + self.assertEqual(self.dna_center.dnac_location_map, expected) + self.assertEqual(actual_floors, EXPECTED_FLOORS) @override_settings(PLUGINS_CONFIG={"nautobot_ssot": {"dna_center_import_global": False}}) def test_build_dnac_location_map_wo_global(self): """Test Nautobot adapter build_dnac_location_map method without global.""" self.dna_center.dnac_location_map = {} - actual = self.dna_center.build_dnac_location_map(locations=LOCATION_FIXTURE) + self.dna_center.build_dnac_location_map(locations=LOCATION_WO_GLOBAL_FIXTURE) expected = EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL - self.assertEqual(sorted(actual), sorted(expected)) + self.assertEqual(self.dna_center.dnac_location_map, expected) - def test_parse_and_sort_locations(self): - """Test Nautobot adapter parse_and_sort_locations method.""" - actual_areas, actual_buildings, actual_floors = self.dna_center.parse_and_sort_locations( - locations=LOCATION_FIXTURE + def test_build_dnac_location_map_w_job_location_map(self): + """Test Nautobot adapter build_dnac_location_map method when used with the Job location map.""" + self.dna_center.dnac_location_map = {} + self.job.location_map = { + "SanJose": {"name": "San Jose", "parent": "Califonia", "area_parent": "USA"}, + "Antartica2": {"name": "South Pole"}, + } + self.dna_center.build_dnac_location_map(locations=LOCATION_FIXTURE) + self.assertEqual( + sorted(self.dna_center.dnac_location_map), sorted(EXPECTED_DNAC_LOCATION_MAP_W_JOB_LOCATION_MAP) ) - self.assertEqual(actual_areas, EXPECTED_AREAS) - self.assertEqual(actual_buildings, EXPECTED_BUILDINGS) - self.assertEqual(actual_floors, EXPECTED_FLOORS) def test_load_locations_success(self): """Test Nautobot SSoT for Cisco DNA Center load_locations() function successfully.""" - self.dna_center.load_buildings = MagicMock() - self.dna_center.load_floors = MagicMock() + self.dna_center.build_dnac_location_map = MagicMock() self.dna_center_client.get_location.return_value = [{"name": "NY"}] self.dna_center.load_locations() self.dna_center_client.get_locations.assert_called() - self.dna_center.load_buildings.assert_called_once() - self.dna_center.load_floors.assert_called_once() + self.dna_center.build_dnac_location_map.assert_called_once() def test_load_locations_failure(self): """Test Nautobot SSoT for Cisco DNA Center load_locations() function fails.""" self.dna_center_client.get_locations.return_value = [] self.dna_center.load_locations() self.dna_center.job.logger.error.assert_called_once_with( - "No location data was returned from DNAC. Unable to proceed." + "No location data was returned from DNA Center. Unable to proceed." ) - def test_load_area_w_global(self): - """Test Nautobot SSoT for Cisco DNA Center load_area() function with Global area.""" - for area in EXPECTED_AREAS: - hierarchy = area["siteNameHierarchy"].split("/") - if isinstance(hierarchy, list) and len(hierarchy) > 1: - self.dna_center.load_area(area=hierarchy[-1], area_parent=hierarchy[-2]) - else: - self.dna_center.load_area(area=hierarchy[0]) - area_expected = sorted( - [f"{x['name']}__{x['parent']}" for x in EXPECTED_DNAC_LOCATION_MAP.values() if x["loc_type"] == "area"] + def test_load_device_location_tree_w_floor(self): + """Test Nautobot SSoT for Cisco DNA Center load_device_location_tree() function with Device that has floor Location.""" + self.dna_center.dnac_location_map = { + "1": { + "name": "Global", + "parent": None, + "parent_of_parent": None, + }, + "2": { + "name": "USA", + "parent": "Global", + "parent_of_parent": None, + }, + "3": { + "name": "New York", + "parent": "USA", + "parent_of_parent": "Global", + }, + "4": { + "name": "NYC", + "parent": "New York", + "parent_of_parent": "USA", + }, + "5": {"name": "HQ", "parent": "NYC", "parent_of_parent": "New York"}, + "6": {"name": "1st Floor", "parent": "HQ"}, + } + self.dna_center.building_map = { + "5": { + "name": "HQ", + "id": "5", + "parentId": "4", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "United States", + "address": "123 Broadway, New York City, New York 12345, United States", + "latitude": "40.758746", + "addressInheritedFrom": "2", + "type": "building", + "longitude": "-73.978660", + }, + } + ], + "siteHierarchy": "/1/2/3/4/5/", + }, + } + mock_loc_data = {"areas": ["Global", "USA", "New York", "NYC"], "building": "HQ", "floor": "1st Floor"} + mock_dev_details = {"siteHierarchyGraphId": "/1/2/3/4/5/6/"} + self.dna_center.load_device_location_tree(dev_details=mock_dev_details, loc_data=mock_loc_data) + self.assertEqual( + {"HQ - 1st Floor__HQ"}, + {dev.get_unique_id() for dev in self.dna_center.get_all("floor")}, + ) + self.assertEqual( + {"HQ__NYC"}, + {dev.get_unique_id() for dev in self.dna_center.get_all("building")}, + ) + loaded_bldgs = self.dna_center.get_all("building") + self.assertEqual(loaded_bldgs[0].area_parent, "New York") + self.assertEqual( + {"Global__None", "USA__Global", "New York__USA", "NYC__New York"}, + {dev.get_unique_id() for dev in self.dna_center.get_all("area")}, ) - area_actual = sorted([area.get_unique_id() for area in self.dna_center.get_all("area")]) - self.assertEqual(area_actual, area_expected) - - @override_settings(PLUGINS_CONFIG={"nautobot_ssot": {"dna_center_import_global": False}}) - def test_load_area_wo_global(self): - """Test Nautobot SSoT for Cisco DNA Center load_area() function without Global area.""" - self.dna_center.dnac_location_map = EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL - for area in EXPECTED_AREAS_WO_GLOBAL: - hierarchy = area["siteNameHierarchy"].split("/") - if hierarchy[-2] == "Global": - area_parent = None - else: - area_parent = hierarchy[-2] - self.dna_center.load_area(area=hierarchy[-1], area_parent=area_parent) - area_expected = [ - f"{x['name']}__{x['parent']}" - for x in EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL.values() - if x["loc_type"] == "area" - ] - area_actual = [area.get_unique_id() for area in self.dna_center.get_all("area")] - self.assertEqual(sorted(area_actual), sorted(area_expected)) - - def test_load_buildings_w_global(self): - """Test Nautobot SSoT for Cisco DNA Center load_buildings() function with Global area.""" - self.dna_center_client.find_address_and_type.side_effect = [ - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ] - self.dna_center.load_buildings(buildings=EXPECTED_BUILDINGS) - building_expected = [ - f"{x['name']}__{x['parent']}" for x in EXPECTED_DNAC_LOCATION_MAP.values() if x["loc_type"] == "building" - ] - building_actual = [building.get_unique_id() for building in self.dna_center.get_all("building")] - self.assertEqual(building_actual, building_expected) - - def test_load_buildings_wo_global(self): - """Test Nautobot SSoT for Cisco DNA Center load_buildings() function without Global area.""" - self.dna_center_client.find_address_and_type.side_effect = [ - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ] - self.dna_center.dnac_location_map = EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL - self.dna_center.load_buildings(buildings=EXPECTED_BUILDINGS) - building_expected = [ - f"{x['name']}__{x['parent']}" - for x in EXPECTED_DNAC_LOCATION_MAP_WO_GLOBAL.values() - if x["loc_type"] == "building" - ] - building_actual = [building.get_unique_id() for building in self.dna_center.get_all("building")] - self.assertEqual(sorted(building_actual), sorted(building_expected)) - - def test_load_buildings_duplicate(self): - """Test Nautobot SSoT for Cisco DNA Center load_buildings() function with duplicate building.""" - self.dna_center.load_area = MagicMock() - self.dna_center_client.find_address_and_type.side_effect = [ - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ("", "building"), - ] - self.dna_center.load_buildings(buildings=EXPECTED_BUILDINGS) - self.dna_center.load_buildings(buildings=[EXPECTED_BUILDINGS[0]]) - self.dna_center.job.logger.warning.assert_called_with("Site Building1 already loaded so skipping.") - - def test_load_buildings_w_location_map_building_change(self): - """Test Nautobot SSoT for Cisco DNA Center load_buildings() function with Job location_map and building data.""" - self.job.location_map = {"Rome": {"parent": "Italy", "area_parent": "Europe"}} - self.dna_center_client.find_address_and_type.side_effect = [("", "building")] - - self.dna_center.load_area = MagicMock() - self.dna_center.load_buildings(buildings=[EXPECTED_BUILDINGS[7]]) - self.dna_center.load_area.assert_called_with(area="Italy", area_parent="Europe") - loaded_bldg = self.dna_center.get("building", {"name": "Rome", "area": "Italy"}) - self.assertEqual(loaded_bldg.name, "Rome") - self.assertEqual(loaded_bldg.area, "Italy") - self.assertEqual(loaded_bldg.area_parent, "Europe") - - def test_load_buildings_w_location_map_area_change(self): - """Test Nautobot SSoT for Cisco DNA Center load_buildings() function with Job location_map and area data.""" - self.job.location_map = {"SanDiego": {"parent": "California"}} - self.dna_center_client.find_address_and_type.side_effect = [("", "building")] - - self.dna_center.load_area = MagicMock() - self.dna_center.load_buildings(buildings=[EXPECTED_BUILDINGS[5]]) - self.dna_center.load_area.assert_called_with(area="SanDiego", area_parent="California") - loaded_bldg = self.dna_center.get("building", {"name": "1", "area": "SanDiego"}) - self.assertEqual(loaded_bldg.name, "1") - self.assertEqual(loaded_bldg.area, "SanDiego") - self.assertEqual(loaded_bldg.area_parent, "California") - - def test_load_buildings_w_location_map_area_and_bldg_change(self): - """Test Nautobot SSoT for Cisco DNA Center load_buildings() function with Job location_map and area and building data.""" - self.job.location_map = {"HQ": {"parent": "New York", "area_parent": "USA"}, "New York": {"parent": "New York"}} - self.dna_center_client.find_address_and_type.side_effect = [("", "building")] - self.dna_center.load_area = MagicMock() - test_bldg = [ - { - "parentId": "04509ce4-6c88-40a3-b444-9e00f2cd97f2", + def test_load_device_location_tree_wo_floor(self): + """Test Nautobot SSoT for Cisco DNA Center load_device_location_tree() function with Device that doesn't have a floor Location.""" + self.dna_center.dnac_location_map = { + "1": { + "name": "Global", + "parent": None, + "parent_of_parent": None, + }, + "2": { + "name": "USA", + "parent": "Global", + "parent_of_parent": None, + }, + "3": { + "name": "New York", + "parent": "USA", + "parent_of_parent": "Global", + }, + "4": { + "name": "NYC", + "parent": "New York", + "parent_of_parent": "USA", + }, + "5": {"name": "HQ", "parent": "NYC", "parent_of_parent": "New York"}, + } + self.dna_center.building_map = { + "5": { + "name": "HQ", + "id": "5", + "parentId": "4", "additionalInfo": [ { "nameSpace": "Location", "attributes": { - "latitude": "39.156233", + "country": "United States", + "address": "123 Broadway, New York City, New York 12345, United States", + "latitude": "40.758746", + "addressInheritedFrom": "2", "type": "building", - "longitude": "-74.690192", + "longitude": "-73.978660", }, } ], - "name": "HQ", - "id": "2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", - "siteHierarchy": "9e5f9fc2-032e-45e8-994c-4a00629648e8/04509ce4-6c88-40a3-b444-9e00f2cd97f2/2d0d8545-b6de-4dda-a0f7-93bcd7d0e738", - "siteNameHierarchy": "Global/SanDiego/HQ", - } - ] - self.dna_center.load_buildings(buildings=test_bldg) - self.dna_center.load_area.assert_called_with(area="New York", area_parent="USA") - loaded_bldg = self.dna_center.get("building", {"name": "HQ", "area": "New York"}) - self.assertEqual(loaded_bldg.name, "HQ") - self.assertEqual(loaded_bldg.area, "New York") - self.assertEqual(loaded_bldg.area_parent, "USA") - - def test_load_floors(self): - """Test Nautobot SSoT for Cisco DNA Center load_floors() function.""" - self.job.location_map = {} - self.dna_center.get = MagicMock() - self.dna_center.load_floors(floors=EXPECTED_FLOORS) - floor_expected = [ - "Building1 - Floor1__Building1", - "1 - 1__1", - "Deep Space - 1st Floor__Deep Space", - "Building 1 - Lab__Building 1", - "secretlab - Floor 2__secretlab", - ] - floor_actual = [floor.get_unique_id() for floor in self.dna_center.get_all("floor")] - self.assertEqual(floor_actual, floor_expected) - - def test_load_floors_missing_parent(self): - """Test Nautobot SSoT for Cisco DNA Center load_floors() function with missing parent.""" - self.dna_center.dnac_location_map = {} - self.dna_center.load_floors(floors=EXPECTED_FLOORS) - self.dna_center.job.logger.warning.assert_called_with("Parent to Floor 2 can't be found so will be skipped.") + "siteHierarchy": "/1/2/3/4/5/", + }, + } + mock_loc_data = {"areas": ["Global", "USA", "New York", "NYC"], "building": "HQ"} + mock_dev_details = {"siteHierarchyGraphId": "/1/2/3/4/5/"} + self.dna_center.load_device_location_tree(dev_details=mock_dev_details, loc_data=mock_loc_data) + self.assertEqual(len(self.dna_center.get_all("floor")), 0) def test_load_devices(self): """Test Nautobot SSoT for Cisco DNA Center load_devices() function.""" diff --git a/nautobot_ssot/tests/dna_center/test_models_nautobot.py b/nautobot_ssot/tests/dna_center/test_models_nautobot.py index 3cd39b65c..5d0112274 100644 --- a/nautobot_ssot/tests/dna_center/test_models_nautobot.py +++ b/nautobot_ssot/tests/dna_center/test_models_nautobot.py @@ -12,6 +12,7 @@ Location, LocationType, Manufacturer, + Platform, ) from nautobot.extras.models import Role, Status from nautobot.tenancy.models import Tenant @@ -290,6 +291,8 @@ def setUp(self): self.adapter.device_map = {} self.adapter.floor_map = {} self.adapter.site_map = {} + ios_platform = Platform.objects.get_or_create(name="IOS", network_driver="cisco_ios")[0] + self.adapter.platform_map = {"cisco_ios": ios_platform.id} self.adapter.status_map = {"Active": self.status_active.id} self.adapter.tenant_map = {"G&A": self.ga_tenant.id} diff --git a/nautobot_ssot/tests/infoblox/test_tags_and_cfs.py b/nautobot_ssot/tests/infoblox/test_tags_and_cfs.py index f4113c490..e5b91dda3 100644 --- a/nautobot_ssot/tests/infoblox/test_tags_and_cfs.py +++ b/nautobot_ssot/tests/infoblox/test_tags_and_cfs.py @@ -127,8 +127,7 @@ def test_objects_synced_to_infoblox_are_tagged(self): """Ensure objects synced to Infoblox have 'SSoT Synced to Infoblox' tag applied.""" create_prefix_relationship() nb_prefix = Prefix( - network="10.0.0.0", - prefix_length=8, + prefix="10.0.0.0/8", description="Test Network", type="network", status=Status.objects.get_for_model(Prefix).first(), @@ -204,8 +203,7 @@ def test_cfs_have_correct_content_types_set(self): def test_cf_updated_for_objects_synced_to_infoblox(self): """Ensure objects synced to Infoblox have cf 'ssot_synced_to_infoblox' correctly updated.""" nb_prefix = Prefix( - network="10.0.0.0", - prefix_length=8, + prefix="10.0.0.0/8", description="Test Network", type="network", status=Status.objects.get_for_model(Prefix).first(), diff --git a/nautobot_ssot/tests/librenms/__init__.py b/nautobot_ssot/tests/librenms/__init__.py new file mode 100644 index 000000000..a26881083 --- /dev/null +++ b/nautobot_ssot/tests/librenms/__init__.py @@ -0,0 +1 @@ +"""Unit tests for nautobot_ssot_librenms app.""" diff --git a/nautobot_ssot/tests/librenms/fixtures/__init__.py b/nautobot_ssot/tests/librenms/fixtures/__init__.py new file mode 100644 index 000000000..2012108ad --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/__init__.py @@ -0,0 +1,13 @@ +"""Fixtures for tests.""" + +import json + + +def load_json(path): + """Load a json file.""" + with open(path, encoding="utf-8") as file: + return json.loads(file.read()) + + +DEVICE_FIXTURE_RECV = load_json("./nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json")["devices"] +LOCATION_FIXURE_RECV = load_json("./nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json")["locations"] diff --git a/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json b/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json new file mode 100644 index 000000000..b254b9c88 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json @@ -0,0 +1,120 @@ +{ + "status": "ok", + "devices": [ + { + "device_id": 7, + "inserted": "2023-04-21 23:01:34", + "hostname": "10.0.10.11", + "sysName": "grch-ap-p2-utpo-303-60", + "display": null, + "ip": "10.0.10.11", + "overwrite_ip": null, + "community": null, + "authlevel": "authPriv", + "authalgo": "SHA", + "cryptoalgo": "AES", + "snmpver": "v3", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.14988.1", + "sysDescr": "RouterOS RBwAPG-60ad", + "sysContact": "comany-x ", + "version": "7.8", + "hardware": "RBwAPG-60ad", + "features": "Level 3", + "location_id": 1, + "os": "routeros", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 22134355, + "agent_uptime": 0, + "last_polled": "2023-12-15 13:30:21", + "last_poll_attempted": null, + "last_polled_timetaken": 14.317140102386, + "last_discovered_timetaken": 27.313, + "last_discovered": "2023-12-15 12:36:47", + "last_ping": "2023-12-15 13:30:08", + "last_ping_timetaken": 0.309, + "purpose": null, + "type": "network", + "serial": "HE508HJDKED", + "icon": "mikrotik.svg", + "poller_group": 1, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 3, + "disable_notify": 0, + "ignore_status": 0, + "dependency_parent_id": "1", + "dependency_parent_hostname": "10.0.255.255", + "location": "City Hall", + "lat": null, + "lng": null + }, + { + "device_id": 1, + "inserted": "2023-03-22 12:19:34", + "hostname": "10.0.255.255", + "sysName": "grch-rt-core", + "display": null, + "ip": "10.0.255.255", + "overwrite_ip": "", + "community": null, + "authlevel": "authPriv", + "authalgo": "SHA", + "cryptoalgo": "AES", + "snmpver": "v3", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.14988.1", + "sysDescr": "RouterOS RB5009UPr+S+", + "sysContact": "comany-x ", + "version": "7.8", + "hardware": "RB5009UPr+S+", + "features": "Level 5", + "location_id": 1, + "os": "routeros", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 22136032, + "agent_uptime": 0, + "last_polled": "2023-12-15 13:30:25", + "last_poll_attempted": null, + "last_polled_timetaken": 19.380449056625, + "last_discovered_timetaken": 41.965, + "last_discovered": "2023-12-15 12:33:46", + "last_ping": "2023-12-15 13:30:08", + "last_ping_timetaken": 0.388, + "purpose": "", + "type": "network", + "serial": "HE108PIEJR2", + "icon": "mikrotik.svg", + "poller_group": 1, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 2, + "disable_notify": 0, + "ignore_status": 0, + "dependency_parent_id": "4", + "dependency_parent_hostname": "localhost", + "location": "GYM", + "lat": null, + "lng": null + } + ], + "count": 2 +} \ No newline at end of file diff --git a/nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json b/nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json new file mode 100644 index 000000000..9944689db --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json @@ -0,0 +1,22 @@ +{ + "status": "ok", + "locations": [ + { + "id": 1, + "location": "City Hall", + "lat": 41.874677429096174, + "lng": -87.62672768379687, + "timestamp": "2023-03-22 15:18:13", + "fixed_coordinates": 1 + }, + { + "id": 2, + "location": "GYM", + "lat": null, + "lng": null, + "timestamp": "2023-03-22 15:18:40", + "fixed_coordinates": 1 + } + ], + "count": 2 +} \ No newline at end of file diff --git a/nautobot_ssot/tests/librenms/fixtures/get_librenms_port_detail.json b/nautobot_ssot/tests/librenms/fixtures/get_librenms_port_detail.json new file mode 100644 index 000000000..d52e71159 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/get_librenms_port_detail.json @@ -0,0 +1,75 @@ +{ + "status": "ok", + "port": [ + { + "port_id": 11, + "device_id": 1, + "port_descr_type": null, + "port_descr_descr": null, + "port_descr_circuit": null, + "port_descr_speed": null, + "port_descr_notes": null, + "ifDescr": "ether1", + "ifName": "ether1", + "portName": null, + "ifIndex": 1, + "ifSpeed": 1000000000, + "ifSpeed_prev": 1000000000, + "ifConnectorPresent": null, + "ifOperStatus": "up", + "ifOperStatus_prev": "up", + "ifAdminStatus": "up", + "ifAdminStatus_prev": "up", + "ifDuplex": null, + "ifMtu": 1500, + "ifType": "ethernetCsmacd", + "ifAlias": "ge to internet", + "ifPhysAddress": "48a98a3453eb", + "ifLastChange": 155546602, + "ifVlan": "", + "ifTrunk": null, + "ifVrf": 0, + "ignore": 0, + "disabled": 0, + "deleted": 0, + "pagpOperationMode": null, + "pagpPortState": null, + "pagpPartnerDeviceId": null, + "pagpPartnerLearnMethod": null, + "pagpPartnerIfIndex": null, + "pagpPartnerGroupIfIndex": null, + "pagpPartnerDeviceName": null, + "pagpEthcOperationMode": null, + "pagpDeviceId": null, + "pagpGroupIfIndex": null, + "ifInUcastPkts": 772050550, + "ifInUcastPkts_prev": 772041971, + "ifInUcastPkts_delta": 8579, + "ifInUcastPkts_rate": 29, + "ifOutUcastPkts": 2125079832, + "ifOutUcastPkts_prev": 2125070249, + "ifOutUcastPkts_delta": 9583, + "ifOutUcastPkts_rate": 32, + "ifInErrors": 0, + "ifInErrors_prev": 0, + "ifInErrors_delta": 0, + "ifInErrors_rate": 0, + "ifOutErrors": 0, + "ifOutErrors_prev": 0, + "ifOutErrors_delta": 0, + "ifOutErrors_rate": 0, + "ifInOctets": 297929987779, + "ifInOctets_prev": 297925470017, + "ifInOctets_delta": 4517762, + "ifInOctets_rate": 15059, + "ifOutOctets": 2455908521092, + "ifOutOctets_prev": 2455906739361, + "ifOutOctets_delta": 1781731, + "ifOutOctets_rate": 5939, + "poll_time": 1702668619, + "poll_prev": 1702668319, + "poll_period": 300 + } + ], + "count": 1 +} \ No newline at end of file diff --git a/nautobot_ssot/tests/librenms/fixtures/get_librenms_ports.json b/nautobot_ssot/tests/librenms/fixtures/get_librenms_ports.json new file mode 100644 index 000000000..feffdb133 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/get_librenms_ports.json @@ -0,0 +1,461 @@ +{ + "status": "ok", + "ports": [ + { + "port_id": 1, + "ifName": "lo" + }, + { + "port_id": 2, + "ifName": "eth0" + }, + { + "port_id": 3, + "ifName": "wlan0" + }, + { + "port_id": 11, + "ifName": "ether1" + }, + { + "port_id": 12, + "ifName": "ether2" + }, + { + "port_id": 13, + "ifName": "ether3" + }, + { + "port_id": 14, + "ifName": "ether4" + }, + { + "port_id": 15, + "ifName": "ether5" + }, + { + "port_id": 16, + "ifName": "ether6" + }, + { + "port_id": 17, + "ifName": "ether7" + }, + { + "port_id": 18, + "ifName": "ether8" + }, + { + "port_id": 19, + "ifName": "lan_bridge" + }, + { + "port_id": 20, + "ifName": "lo0" + }, + { + "port_id": 21, + "ifName": "v2-OOBM" + }, + { + "port_id": 22, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 23, + "ifName": "v20-CORP-WIRELESS" + }, + { + "port_id": 24, + "ifName": "v30-CORP-WIRED" + }, + { + "port_id": 25, + "ifName": "v40-SECURITY-DEVICES" + }, + { + "port_id": 26, + "ifName": "v50-SERVERS" + }, + { + "port_id": 27, + "ifName": "zerotier1" + }, + { + "port_id": 28, + "ifName": "ether1" + }, + { + "port_id": 29, + "ifName": "ether2" + }, + { + "port_id": 30, + "ifName": "ether3" + }, + { + "port_id": 31, + "ifName": "ether4" + }, + { + "port_id": 32, + "ifName": "ether5" + }, + { + "port_id": 33, + "ifName": "ether6" + }, + { + "port_id": 34, + "ifName": "ether7" + }, + { + "port_id": 35, + "ifName": "ether8" + }, + { + "port_id": 36, + "ifName": "lan_bridge" + }, + { + "port_id": 37, + "ifName": "lo0" + }, + { + "port_id": 38, + "ifName": "v2-OOBM" + }, + { + "port_id": 39, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 40, + "ifName": "v20-CORP-WIRELESS" + }, + { + "port_id": 41, + "ifName": "v30-CORP-WIRED" + }, + { + "port_id": 42, + "ifName": "v40-SECURITY-DEVICES" + }, + { + "port_id": 43, + "ifName": "v50-SERVERS" + }, + { + "port_id": 44, + "ifName": "zerotier1" + }, + { + "port_id": 45, + "ifName": "lo" + }, + { + "port_id": 46, + "ifName": "ens18" + }, + { + "port_id": 49, + "ifName": "wg0" + }, + { + "port_id": 51, + "ifName": "docker0" + }, + { + "port_id": 62, + "ifName": "temp-vlan3-camera-setup" + }, + { + "port_id": 63, + "ifName": "wg0" + }, + { + "port_id": 64, + "ifName": "wlan60-1" + }, + { + "port_id": 65, + "ifName": "ether1" + }, + { + "port_id": 66, + "ifName": "wlan60-1" + }, + { + "port_id": 67, + "ifName": "bridge" + }, + { + "port_id": 68, + "ifName": "ether1" + }, + { + "port_id": 69, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 70, + "ifName": "bridge" + }, + { + "port_id": 71, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 72, + "ifName": "wlan60-1" + }, + { + "port_id": 73, + "ifName": "ether1" + }, + { + "port_id": 74, + "ifName": "bridge" + }, + { + "port_id": 75, + "ifName": "wlan60-1" + }, + { + "port_id": 76, + "ifName": "wlan60-station-1" + }, + { + "port_id": 77, + "ifName": "wlan60-1" + }, + { + "port_id": 78, + "ifName": "wlan60-1" + }, + { + "port_id": 79, + "ifName": "ether1" + }, + { + "port_id": 80, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 81, + "ifName": "ether1" + }, + { + "port_id": 82, + "ifName": "bridge" + }, + { + "port_id": 83, + "ifName": "ether1" + }, + { + "port_id": 84, + "ifName": "bridge" + }, + { + "port_id": 85, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 86, + "ifName": "bridge" + }, + { + "port_id": 87, + "ifName": "wlan60-station-1" + }, + { + "port_id": 88, + "ifName": "wlan60-station-1" + }, + { + "port_id": 89, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 90, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 91, + "ifName": "ether1" + }, + { + "port_id": 92, + "ifName": "ether2" + }, + { + "port_id": 93, + "ifName": "ether3" + }, + { + "port_id": 94, + "ifName": "ether4" + }, + { + "port_id": 95, + "ifName": "ether5" + }, + { + "port_id": 96, + "ifName": "lan_bridge" + }, + { + "port_id": 97, + "ifName": "mgmt" + }, + { + "port_id": 98, + "ifName": "ether1" + }, + { + "port_id": 99, + "ifName": "wlan60-1" + }, + { + "port_id": 100, + "ifName": "bridge" + }, + { + "port_id": 101, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 102, + "ifName": "wlan60-station-1" + }, + { + "port_id": 103, + "ifName": "ether1" + }, + { + "port_id": 104, + "ifName": "ether2" + }, + { + "port_id": 105, + "ifName": "ether3" + }, + { + "port_id": 106, + "ifName": "ether4" + }, + { + "port_id": 107, + "ifName": "ether5" + }, + { + "port_id": 109, + "ifName": "lan_bridge" + }, + { + "port_id": 111, + "ifName": "mgmt" + }, + { + "port_id": 121, + "ifName": "remote_support" + }, + { + "port_id": 122, + "ifName": "remote_support" + }, + { + "port_id": 123, + "ifName": "v1000-GUEST-FRONTHALL" + }, + { + "port_id": 124, + "ifName": "v1000-GUEST-FRONTHALL" + }, + { + "port_id": 125, + "ifName": "lo" + }, + { + "port_id": 126, + "ifName": "eth0" + }, + { + "port_id": 127, + "ifName": "wlan0" + }, + { + "port_id": 128, + "ifName": "wg0" + }, + { + "port_id": 129, + "ifName": "v100-EXTRA-MGMT" + }, + { + "port_id": 130, + "ifName": "v200-EXTRA-LANEXTENSION" + }, + { + "port_id": 131, + "ifName": "v100-EXTRA-MGMT" + }, + { + "port_id": 132, + "ifName": "v200-EXTRA-LANEXTENTION" + }, + { + "port_id": 139, + "ifName": "ztbtoqmqf4" + }, + { + "port_id": 140, + "ifName": "ztbtoqmqf4" + }, + { + "port_id": 148, + "ifName": "ether1" + }, + { + "port_id": 149, + "ifName": "wlan60-1" + }, + { + "port_id": 150, + "ifName": "bridge" + }, + { + "port_id": 151, + "ifName": "ether1" + }, + { + "port_id": 152, + "ifName": "v10-MANAGEMENT" + }, + { + "port_id": 153, + "ifName": "ether2" + }, + { + "port_id": 154, + "ifName": "ether3" + }, + { + "port_id": 155, + "ifName": "ether4" + }, + { + "port_id": 156, + "ifName": "ether5" + }, + { + "port_id": 157, + "ifName": "lan_bridge" + }, + { + "port_id": 158, + "ifName": "mgmt" + } + ] +} \ No newline at end of file diff --git a/nautobot_ssot/tests/librenms/test_librenms_adapter.py b/nautobot_ssot/tests/librenms/test_librenms_adapter.py new file mode 100644 index 000000000..bf0765083 --- /dev/null +++ b/nautobot_ssot/tests/librenms/test_librenms_adapter.py @@ -0,0 +1,76 @@ +"""Unit test for LibreNMS object models.""" + +from unittest.mock import MagicMock + +from django.contrib.contenttypes.models import ContentType +from nautobot.core.testing import TransactionTestCase +from nautobot.dcim.models import Device, Location +from nautobot.extras.models import JobResult, Status + +from nautobot_ssot.integrations.librenms.diffsync.adapters.librenms import LibrenmsAdapter +from nautobot_ssot.integrations.librenms.jobs import LibrenmsDataSource +from nautobot_ssot.tests.librenms.fixtures import DEVICE_FIXTURE_RECV, LOCATION_FIXURE_RECV + + +class TestLibreNMSAdapterTestCase(TransactionTestCase): + """Test NautobotSsotLibreNMSAdapter class.""" + + databases = ("default", "job_logs") + + def __init__(self, *args, **kwargs): + """Initialize test case.""" + super().__init__(*args, **kwargs) + + def setUp(self): + """Setup shared objects for tests.""" + # Create Active status first + self.active_status, _ = Status.objects.get_or_create( + name="Active", + defaults={ + "color": "4caf50", + }, + ) + self.active_status.content_types.add(ContentType.objects.get_for_model(Device)) + self.active_status.content_types.add(ContentType.objects.get_for_model(Location)) + + self.librenms_client = MagicMock() + self.librenms_client.name = "Test" + self.librenms_client.remote_url = "https://test.com" + self.librenms_client.verify_ssl = True + + # Mock device and location data + self.librenms_client.get_librenms_devices_from_file.return_value = { + "count": len(DEVICE_FIXTURE_RECV), + "devices": DEVICE_FIXTURE_RECV, + } + self.librenms_client.get_librenms_locations_from_file.return_value = { + "count": len(LOCATION_FIXURE_RECV), + "locations": LOCATION_FIXURE_RECV, + } + + self.job = LibrenmsDataSource() + self.job.load_type = "file" + self.job.hostname_field = "sysName" + self.job.sync_locations = True + self.job.logger.warning = MagicMock() + self.job.sync_locations = True + self.job.job_result = JobResult.objects.create( + name=self.job.class_path, task_name="fake task", worker="default" + ) + self.librenms_adapter = LibrenmsAdapter(job=self.job, sync=None, librenms_api=self.librenms_client) + + def test_data_loading(self): + """Test that devices and locations are loaded correctly.""" + self.librenms_adapter.load() + + # Debugging outputs + print("Adapter Devices:", list(self.librenms_adapter.get_all("device"))) + print("Adapter Locations:", list(self.librenms_adapter.get_all("location"))) + + expected_locations = {loc["location"].strip() for loc in LOCATION_FIXURE_RECV} + loaded_locations = {loc.get_unique_id() for loc in self.librenms_adapter.get_all("location")} + self.assertEqual(expected_locations, loaded_locations, "Locations are not loaded correctly.") + + expected_devices = {dev["sysName"].strip() for dev in DEVICE_FIXTURE_RECV} + loaded_devices = {dev.get_unique_id() for dev in self.librenms_adapter.get_all("device")} + self.assertEqual(expected_devices, loaded_devices, "Devices are not loaded correctly.") diff --git a/nautobot_ssot/tests/librenms/test_nautobot_adapter.py b/nautobot_ssot/tests/librenms/test_nautobot_adapter.py new file mode 100644 index 000000000..3c05eb196 --- /dev/null +++ b/nautobot_ssot/tests/librenms/test_nautobot_adapter.py @@ -0,0 +1,144 @@ +"""Unit test for Nautobot object models.""" + +import json +from unittest.mock import MagicMock + +from django.contrib.contenttypes.models import ContentType +from nautobot.core.testing import TransactionTestCase +from nautobot.dcim.models import Device as ORMDevice +from nautobot.dcim.models import DeviceType, LocationType, Manufacturer, Platform +from nautobot.dcim.models import Location as ORMLocation +from nautobot.extras.models import JobResult, Role, Status + +from nautobot_ssot.integrations.librenms.constants import ( + librenms_status_map, + os_manufacturer_map, +) +from nautobot_ssot.integrations.librenms.diffsync.adapters.nautobot import ( + NautobotAdapter, +) +from nautobot_ssot.integrations.librenms.jobs import LibrenmsDataSource + + +def load_json(path): + """Load a JSON file.""" + with open(path, encoding="utf-8") as file: + return json.load(file) + + +DEVICE_FIXTURE = load_json("./nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json")["devices"] +LOCATION_FIXTURE = load_json("./nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json")["locations"] + + +class TestNautobotAdapterTestCase(TransactionTestCase): + """Test NautobotAdapter class for loading devices from the ORM.""" + + databases = ("default", "job_logs") + + def setUp(self): + """Initialize test case and populate the database.""" + self.active_status, _ = Status.objects.get_or_create(name="Active") + self.active_status.content_types.add(ContentType.objects.get_for_model(ORMDevice)) + + self.site_type, _ = LocationType.objects.get_or_create(name="Site") + self.site_type.content_types.add(ContentType.objects.get_for_model(ORMDevice)) + + for location in LOCATION_FIXTURE: + ORMLocation.objects.create( + name=location["location"], + location_type=self.site_type, + latitude=location.get("lat"), + longitude=location.get("lng"), + status=self.active_status, + ) + + for device in DEVICE_FIXTURE: + location = ORMLocation.objects.get(name=device["location"]) + _manufacturer, _ = Manufacturer.objects.get_or_create(name=os_manufacturer_map[device["os"]]) + _role, _role_created = Role.objects.get_or_create(name=device["type"]) + if _role_created: + _role.content_types.add(ContentType.objects.get_for_model(ORMDevice)) + _status, _ = Status.objects.get_or_create(name=librenms_status_map[device["status"]]) + _device_type, _ = DeviceType.objects.get_or_create(model=device["hardware"], manufacturer=_manufacturer) + _platform, _ = Platform.objects.get_or_create(name=device["os"], manufacturer=_manufacturer) + ORMDevice.objects.create( + name=device["sysName"], + device_type=_device_type, + role=_role, + location=location, + status=_status, + serial=device["serial"], + platform=_platform, + ) + + self.job = LibrenmsDataSource() + self.job.logger.warning = MagicMock() + self.job.sync_locations = True + self.job.job_result = JobResult.objects.create( + name=self.job.class_path, task_name="fake task", worker="default" + ) + + self.nautobot_adapter = NautobotAdapter(job=self.job, sync=None) + + def test_load_devices(self): + """Test that devices are correctly loaded from the Nautobot ORM.""" + self.nautobot_adapter.load() + + loaded_devices = {device.get_unique_id() for device in self.nautobot_adapter.get_all("device")} + + expected_devices = {device["sysName"] for device in DEVICE_FIXTURE} + + self.assertEqual(expected_devices, loaded_devices, "Devices were not loaded correctly.") + + for device in DEVICE_FIXTURE: + loaded_device = self.nautobot_adapter.get("device", {"name": device["sysName"]}) + print(f"Loaded device: {loaded_device}") + print(f"Loaded device type: {type(loaded_device)}") + self.assertIsNotNone(loaded_device, f"Device {device['sysName']} not found in the adapter.") + + def test_load_locations(self): + """Test that locations are correctly loaded from the Nautobot ORM.""" + self.nautobot_adapter.load_location() + + loaded_locations = {location.get_unique_id() for location in self.nautobot_adapter.get_all("location")} + + expected_locations = {location["location"] for location in LOCATION_FIXTURE} + + self.assertEqual(expected_locations, loaded_locations, "Locations were not loaded correctly.") + + for location in LOCATION_FIXTURE: + loaded_location = self.nautobot_adapter.get("location", {"name": location["location"]}) + self.assertIsNotNone(loaded_location, f"Location {location['location']} not found in the adapter.") + + # gps coordinates need to be truncated to 6 decimal places + _latitude = None + _longitude = None + if isinstance(location.get("lng"), float): + _longitude = round(location.get("lng"), 6) + else: + _longitude = location.get("lng") + if isinstance(location.get("lat"), float): + _latitude = round(location.get("lat"), 6) + else: + _latitude = location.get("lat") + + self.assertEqual( + loaded_location.latitude, + _latitude, + f"Latitude mismatch for {location['location']}.", + ) + self.assertEqual( + loaded_location.longitude, + _longitude, + f"Longitude mismatch for {location['location']}.", + ) + self.assertEqual( + loaded_location.status, + "Active", + f"Status mismatch for {location['location']}.", + ) + self.assertEqual( + loaded_location.location_type, + "Site", + f"Location type mismatch for {location['location']}.", + ) diff --git a/nautobot_ssot/tests/solarwinds/__init__.py b/nautobot_ssot/tests/solarwinds/__init__.py new file mode 100644 index 000000000..feef738db --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/__init__.py @@ -0,0 +1 @@ +"""Unit tests for Solarwinds SSoT app.""" diff --git a/nautobot_ssot/tests/solarwinds/conftest.py b/nautobot_ssot/tests/solarwinds/conftest.py new file mode 100644 index 000000000..b68b61e42 --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/conftest.py @@ -0,0 +1,33 @@ +"""Params for testing.""" + +import json + +from nautobot_ssot.integrations.solarwinds.utils.solarwinds import SolarwindsClient + + +def load_json(path): + """Load a json file.""" + with open(path, encoding="utf-8") as file: + return json.loads(file.read()) + + +GET_CONTAINER_NODES_FIXTURE = load_json("./nautobot_ssot/tests/solarwinds/fixtures/get_container_nodes.json") +GET_TOP_LEVEL_CONTAINERS_FIXTURE = load_json("./nautobot_ssot/tests/solarwinds/fixtures/get_top_level_containers.json") +NODE_DETAILS_FIXTURE = load_json("./nautobot_ssot/tests/solarwinds/fixtures/node_details.json") +GET_NODES_CUSTOM_PROPERTY_FIXTURE = load_json( + "./nautobot_ssot/tests/solarwinds/fixtures/get_nodes_custom_property.json" +) + + +def create_solarwinds_client(**kwargs) -> SolarwindsClient: + """Function to initialize a SolarwindsClient object.""" + return SolarwindsClient( # nosec: B106 + hostname=kwargs.pop("hostname", "https://test.solarwinds.com"), + username=kwargs.pop("username", "admin"), + password=kwargs.pop("password", "admin"), + port=kwargs.pop("port", 443), + retries=kwargs.pop("retries", 5), + timeout=kwargs.pop("timeout", 60), + verify=kwargs.pop("verify", True), + job=kwargs.pop("job", None), + ) diff --git a/nautobot_ssot/tests/solarwinds/fixtures/get_container_nodes.json b/nautobot_ssot/tests/solarwinds/fixtures/get_container_nodes.json new file mode 100644 index 000000000..7922c7f21 --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/fixtures/get_container_nodes.json @@ -0,0 +1,16 @@ +{ + "HQ": [ + { + "ContainerID": 1, + "Name": "UNKNOWN_DEVICE_TYPE1", + "MemberEntityType": "Orion.Nodes", + "MemberPrimaryID": 10 + }, + { + "ContainerID": 1, + "Name": "Router01", + "MemberEntityType": "Orion.Nodes", + "MemberPrimaryID": 11 + } + ] +} \ No newline at end of file diff --git a/nautobot_ssot/tests/solarwinds/fixtures/get_nodes_custom_property.json b/nautobot_ssot/tests/solarwinds/fixtures/get_nodes_custom_property.json new file mode 100644 index 000000000..dc2210a97 --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/fixtures/get_nodes_custom_property.json @@ -0,0 +1,10 @@ +[ + { + "Name": "UNKNOWN_DEVICE_TYPE1", + "MemberPrimaryID": 10 + }, + { + "Name": "Router01", + "MemberPrimaryID": 11 + } +] \ No newline at end of file diff --git a/nautobot_ssot/tests/solarwinds/fixtures/get_top_level_containers.json b/nautobot_ssot/tests/solarwinds/fixtures/get_top_level_containers.json new file mode 100644 index 000000000..4fea7f9a9 --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/fixtures/get_top_level_containers.json @@ -0,0 +1,8 @@ +{ + "1": { + "Name": "HQ" + }, + "2": { + "Name": "DC01" + } +} \ No newline at end of file diff --git a/nautobot_ssot/tests/solarwinds/fixtures/node_details.json b/nautobot_ssot/tests/solarwinds/fixtures/node_details.json new file mode 100644 index 000000000..c904835bd --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/fixtures/node_details.json @@ -0,0 +1,106 @@ +{ + "10": { + "NodeHostname": "UNKNOWN_DEVICE_TYPE1", + "NodeID": 10, + "interfaces": { + "TenGigabitEthernet0/0/0": { + "Name": "TenGigabitEthernet0/0/0", + "Enabled": "Up", + "Status": "Up", + "TypeName": "ethernetCsmacd", + "Speed": 10000000000.0, + "MAC": "AA74D2BCD341", + "MTU": 9104 + }, + "TenGigabitEthernet0/1/0": { + "Name": "TenGigabitEthernet0/1/0", + "Enabled": "Unknown", + "Status": "Unknown", + "TypeName": "ethernetCsmacd", + "Speed": 10000000000.0, + "MAC": "B8D028D78C15", + "MTU": 9216 + }, + "TenGigabitEthernet0/1/0.75": { + "Name": "TenGigabitEthernet0/1/0.75", + "Enabled": "Unknown", + "Status": "Unknown", + "TypeName": "l2vlan", + "Speed": 10000000000.0, + "MAC": "G6F260AD2C18", + "MTU": 9216 + } + }, + "ipaddrs": { + "1.1.1.1": { + "IPAddress": "1.1.1.1", + "SubnetMask": 23, + "IPAddressType": "IPv4", + "IntfName": "TenGigabitEthernet0/0/0" + }, + "10.10.1.2": { + "IPAddress": "10.10.1.2", + "SubnetMask": 23, + "IPAddressType": "IPv4", + "IntfName": "TenGigabitEthernet0/1/0.75" + } + } + }, + "11": { + "NodeHostname": "Router01", + "NodeID": 11, + "Version": "03.11.01.E RELEASE SOFTWARE (fc4)", + "IPAddress": "172.16.5.2", + "PFLength": 24, + "SNMPLocation": "LOCATION STRING", + "Vendor": "Cisco", + "DeviceType": "Cisco Catalyst 4500 L3", + "Model": null, + "ServiceTag": null, + "interfaces": { + "TenGigabitEthernet1/1/1": { + "Name": "TenGigabitEthernet1/1/1", + "Enabled": "Unknown", + "Status": "Unknown", + "TypeName": "ethernetCsmacd", + "Speed": 1000000000.0, + "MAC": "F674BD01ADE4", + "MTU": 1500 + }, + "TenGigabitEthernet1/1/2": { + "Name": "TenGigabitEthernet1/1/2", + "Enabled": "Unknown", + "Status": "Unknown", + "TypeName": "ethernetCsmacd", + "Speed": 1000000000.0, + "MAC": "F674BD01ADE5", + "MTU": 1500 + } + }, + "ipaddrs": { + "10.11.1.1": { + "IPAddress": "10.11.1.1", + "SubnetMask": 23, + "IPAddressType": "IPv4", + "IntfName": "TenGigabitEthernet1/1/1" + }, + "10.11.1.2": { + "IPAddress": "10.11.1.2", + "SubnetMask": 23, + "IPAddressType": "IPv4", + "IntfName": "TenGigabitEthernet1/1/2" + }, + "172.16.1.1": { + "IPAddress": "172.16.1.1", + "SubnetMask": 24, + "IPAddressType": "IPv4", + "IntfName": "Ethernet0/1" + } + } + }, + "12": { + "NodeHostname": "net-snmp Device", + "NodeID": 12, + "Vendor": "net-snmp" + } +} \ No newline at end of file diff --git a/nautobot_ssot/tests/solarwinds/test_jobs.py b/nautobot_ssot/tests/solarwinds/test_jobs.py new file mode 100644 index 000000000..c730a0520 --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/test_jobs.py @@ -0,0 +1,123 @@ +"""Tests to validate Job functions.""" + +import uuid +from unittest.mock import MagicMock + +from nautobot.core.testing import TransactionTestCase +from nautobot.dcim.models import LocationType +from nautobot.extras.models import JobResult + +from nautobot_ssot.integrations.solarwinds.jobs import JobConfigError, SolarwindsDataSource + + +class SolarwindsDataSourceTestCase(TransactionTestCase): + """Test the SolarwindsDataSource class.""" + + job_class = SolarwindsDataSource + databases = ("default", "job_logs") + + def setUp(self): + """Per-test setup.""" + super().setUp() + self.job = self.job_class() + self.job.logger.error = MagicMock() + + self.job.job_result = JobResult.objects.create( + name=self.job.class_path, task_name="Fake task", user=None, id=uuid.uuid4() + ) + + def test_validate_containers_blank(self): + """Validate handling of no containers being defined in Job form.""" + self.job.containers = "" + with self.assertRaises(JobConfigError): + self.job.validate_containers() + self.job.logger.error.assert_called_once_with( + "Containers variable must be defined with container name(s) or 'ALL'." + ) + + def test_validate_containers_missing_top(self): + """Validate handling of top container not defined when 'ALL' containers specified.""" + self.job.containers = "ALL" + self.job.top_container = "" + self.job.pull_from = "Containers" + with self.assertRaises(JobConfigError): + self.job.validate_containers() + self.job.logger.error.assert_called_once_with( + "Top Container must be specified if `ALL` Containers are to be imported." + ) + + def test_validate_location_configuration_missing_parent(self): + """Validate handling of validate_location_configuration() when parent Location isn't specified but required.""" + reg_lt = LocationType.objects.create(name="Region") + site_lt = LocationType.objects.create(name="Site", parent=reg_lt) + self.job.location_type = site_lt + self.job.parent = None + with self.assertRaises(JobConfigError): + self.job.validate_location_configuration() + self.job.logger.error.assert_called_once_with("LocationType %s requires Parent Location be specified.", site_lt) + + def test_validate_location_configuration_extra_parent(self): + """Validate handling of validate_location_configuration() when parent Location is specified, but not required.""" + reg_lt = LocationType.objects.create(name="Region") + site_lt = LocationType.objects.create(name="Site") + self.job.location_type = site_lt + self.job.parent = reg_lt + with self.assertRaises(JobConfigError): + self.job.validate_location_configuration() + self.job.logger.error.assert_called_once_with( + "LocationType %s does not require a Parent location, but a Parent location was chosen.", site_lt + ) + + def test_validate_location_configuration_missing_location_type(self): + self.job.pull_from = "Containers" + self.job.location_type = None + self.job.location_override = None + with self.assertRaises(JobConfigError): + self.job.validate_location_configuration() + self.job.logger.error.assert_called_once_with( + "A Location Type must be specified, unless using Location Override." + ) + + def test_validate_location_configuration_missing_device_contenttype(self): + """Validate handling of validate_location_configuration() when Device ContentType on the specified LocationType.""" + site_lt = LocationType.objects.create(name="Site") + self.job.location_type = site_lt + self.job.parent = None + with self.assertRaises(JobConfigError): + self.job.validate_location_configuration() + self.job.logger.error.assert_called_once_with( + "Specified LocationType %s is missing Device ContentType. Please change LocationType or add Device ContentType to %s LocationType and re-run Job.", + site_lt, + site_lt, + ) + + def test_validate_role_map(self): + """Validate handling of validate_role_map() when Role choice isn't specified.""" + self.job.role_map = {"ASR1001": "Router"} + self.job.role_choice = None + with self.assertRaises(JobConfigError): + self.job.validate_role_map() + self.job.logger.error.assert_called_once_with( + "Role Map Matching Attribute must be defined if Role Map is specified." + ) + + def test_validate_custom_property(self): + """Validate handling of validate_custom_property() if Custom Property is missing.""" + self.job.pull_from = "CustomProperty" + self.job.custom_property = None + with self.assertRaises(JobConfigError): + self.job.validate_custom_property() + self.job.logger.error.assert_called_once_with( + "Custom Property value must exist if pulling from Custom Property." + ) + + def test_validate_custom_property_location(self): + """Validate handling of validate_custom_property() when Location Override isn't specified.""" + self.job.pull_from = "CustomProperty" + self.job.custom_property = "Nautobot_Monitoring" + self.job.location_override = None + with self.assertRaises(JobConfigError): + self.job.validate_custom_property() + self.job.logger.error.assert_called_once_with( + "Location Override must be selected if pulling from CustomProperty." + ) diff --git a/nautobot_ssot/tests/solarwinds/test_solarwinds_adapter.py b/nautobot_ssot/tests/solarwinds/test_solarwinds_adapter.py new file mode 100644 index 000000000..40868eedc --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/test_solarwinds_adapter.py @@ -0,0 +1,405 @@ +"""Test Solarwinds adapter.""" + +import uuid +from unittest.mock import MagicMock, call, patch + +from diffsync.enum import DiffSyncModelFlags +from django.contrib.contenttypes.models import ContentType +from nautobot.core.testing import TransactionTestCase +from nautobot.dcim.models import Device, Location, LocationType +from nautobot.extras.models import JobResult, Role, Status + +import nautobot_ssot.tests.solarwinds.conftest as fix # move to fixtures folder? +from nautobot_ssot.integrations.solarwinds.diffsync.adapters.solarwinds import SolarwindsAdapter +from nautobot_ssot.integrations.solarwinds.jobs import SolarwindsDataSource + + +class TestSolarwindsAdapterTestCase(TransactionTestCase): # pylint: disable=too-many-public-methods + """Test NautobotSsotSolarwindsAdapter class.""" + + databases = ("default", "job_logs") + + def setUp(self): # pylint: disable=invalid-name + """Initialize test case.""" + self.status_active = Status.objects.get_or_create(name="Active")[0] + self.status_active.content_types.add(ContentType.objects.get_for_model(Device)) + + self.solarwinds_client = MagicMock() + self.solarwinds_client.get_top_level_containers.return_value = fix.GET_TOP_LEVEL_CONTAINERS_FIXTURE + self.solarwinds_client.get_filtered_container_ids.return_value = {"HQ": 1} + self.solarwinds_client.get_nodes_custom_property.return_value = fix.GET_NODES_CUSTOM_PROPERTY_FIXTURE + self.solarwinds_client.get_container_nodes.return_value = fix.GET_CONTAINER_NODES_FIXTURE + + self.containers = "HQ" + + self.location_type = LocationType.objects.get_or_create(name="Site")[0] + self.location_type.content_types.add(ContentType.objects.get_for_model(Device)) + + self.parent = Location.objects.get_or_create( + name="USA", location_type=LocationType.objects.get_or_create(name="Region")[0], status=self.status_active + )[0] + + self.job = SolarwindsDataSource() + self.job.debug = True + self.job.job_result = JobResult.objects.create( + name=self.job.class_path, task_name="Fake task", user=None, id=uuid.uuid4() + ) + self.job.logger = MagicMock() + self.job.logger.debug = MagicMock() + self.job.logger.error = MagicMock() + self.job.logger.info = MagicMock() + self.job.logger.warning = MagicMock() + self.job.location_type = self.location_type + self.job.parent = self.parent + self.job.default_role = Role.objects.get_or_create(name="Router")[0] + self.solarwinds = SolarwindsAdapter( + job=self.job, + sync=None, + client=self.solarwinds_client, + containers=self.containers, + location_type=self.location_type, + ) + + def test_data_loading_wo_parent(self): + """Test Nautobot SSoT Solarwinds load() function without parent specified.""" + self.solarwinds_client.standardize_device_type.side_effect = ["", "WS-C4500 L3", ""] + self.solarwinds_client.extract_version.return_value = "03.11.01.E" + self.solarwinds_client.build_node_details.return_value = fix.NODE_DETAILS_FIXTURE + self.solarwinds_client.determine_interface_type.return_value = "10gbase-t" + + self.solarwinds.load_parent = MagicMock() + self.solarwinds.load_prefix = MagicMock() + self.solarwinds.load_ipaddress = MagicMock() + self.solarwinds.load_interfaces = MagicMock() + self.solarwinds.load_ipassignment = MagicMock() + + self.solarwinds.load() + self.solarwinds.load_parent.assert_not_called() + self.job.logger.debug.assert_has_calls( + [ + call("Retrieving node details from Solarwinds for HQ."), + call( + 'Node details: {\n "10": {\n "NodeHostname": "UNKNOWN_DEVICE_TYPE1",\n "NodeID": 10,\n "interfaces": {\n "TenGigabitEthernet0/0/0": {\n "Name": "TenGigabitEthernet0/0/0",\n "Enabled": "Up",\n "Status": "Up",\n "TypeName": "ethernetCsmacd",\n "Speed": 10000000000.0,\n "MAC": "AA74D2BCD341",\n "MTU": 9104\n },\n "TenGigabitEthernet0/1/0": {\n "Name": "TenGigabitEthernet0/1/0",\n "Enabled": "Unknown",\n "Status": "Unknown",\n "TypeName": "ethernetCsmacd",\n "Speed": 10000000000.0,\n "MAC": "B8D028D78C15",\n "MTU": 9216\n },\n "TenGigabitEthernet0/1/0.75": {\n "Name": "TenGigabitEthernet0/1/0.75",\n "Enabled": "Unknown",\n "Status": "Unknown",\n "TypeName": "l2vlan",\n "Speed": 10000000000.0,\n "MAC": "G6F260AD2C18",\n "MTU": 9216\n }\n },\n "ipaddrs": {\n "1.1.1.1": {\n "IPAddress": "1.1.1.1",\n "SubnetMask": 23,\n "IPAddressType": "IPv4",\n "IntfName": "TenGigabitEthernet0/0/0"\n },\n "10.10.1.2": {\n "IPAddress": "10.10.1.2",\n "SubnetMask": 23,\n "IPAddressType": "IPv4",\n "IntfName": "TenGigabitEthernet0/1/0.75"\n }\n }\n },\n "11": {\n "NodeHostname": "Router01",\n "NodeID": 11,\n "Version": "03.11.01.E RELEASE SOFTWARE (fc4)",\n "IPAddress": "172.16.5.2",\n "PFLength": 24,\n "SNMPLocation": "LOCATION STRING",\n "Vendor": "Cisco",\n "DeviceType": "Cisco Catalyst 4500 L3",\n "Model": null,\n "ServiceTag": null,\n "interfaces": {\n "TenGigabitEthernet1/1/1": {\n "Name": "TenGigabitEthernet1/1/1",\n "Enabled": "Unknown",\n "Status": "Unknown",\n "TypeName": "ethernetCsmacd",\n "Speed": 1000000000.0,\n "MAC": "F674BD01ADE4",\n "MTU": 1500\n },\n "TenGigabitEthernet1/1/2": {\n "Name": "TenGigabitEthernet1/1/2",\n "Enabled": "Unknown",\n "Status": "Unknown",\n "TypeName": "ethernetCsmacd",\n "Speed": 1000000000.0,\n "MAC": "F674BD01ADE5",\n "MTU": 1500\n }\n },\n "ipaddrs": {\n "10.11.1.1": {\n "IPAddress": "10.11.1.1",\n "SubnetMask": 23,\n "IPAddressType": "IPv4",\n "IntfName": "TenGigabitEthernet1/1/1"\n },\n "10.11.1.2": {\n "IPAddress": "10.11.1.2",\n "SubnetMask": 23,\n "IPAddressType": "IPv4",\n "IntfName": "TenGigabitEthernet1/1/2"\n },\n "172.16.1.1": {\n "IPAddress": "172.16.1.1",\n "SubnetMask": 24,\n "IPAddressType": "IPv4",\n "IntfName": "Ethernet0/1"\n }\n }\n },\n "12": {\n "NodeHostname": "net-snmp Device",\n "NodeID": 12,\n "Vendor": "net-snmp"\n }\n}' + ), + ] + ) + self.assertEqual( + { + dev["NodeHostname"] + for _, dev in fix.NODE_DETAILS_FIXTURE.items() + if dev.get("Model") or dev.get("DeviceType") + }, + {dev.get_unique_id() for dev in self.solarwinds.get_all("device")}, + ) + self.solarwinds.load_prefix.assert_called() + self.solarwinds.load_prefix.assert_has_calls( + [ + call(network="172.16.5.0/24"), + call(network="10.11.0.0/23"), + call(network="10.11.0.0/23"), + call(network="172.16.1.0/24"), + ] + ) + self.solarwinds.load_ipaddress.assert_called() + self.solarwinds.load_ipaddress.assert_has_calls( + [ + call(addr="172.16.5.2", prefix_length=24, prefix="172.16.5.0/24", addr_type="IPv4"), + call(addr="10.11.1.1", prefix_length=23, prefix="10.11.0.0/23", addr_type="IPv4"), + call(addr="10.11.1.2", prefix_length=23, prefix="10.11.0.0/23", addr_type="IPv4"), + call(addr="172.16.1.1", prefix_length=24, prefix="172.16.1.0/24", addr_type="IPv4"), + ] + ) + + loaded_dev = self.solarwinds.get("device", "Router01") + self.solarwinds.load_interfaces.assert_called() + self.solarwinds.load_interfaces.assert_has_calls( + [ + call(device=loaded_dev, intfs=fix.NODE_DETAILS_FIXTURE["11"]["interfaces"]), + call(device=loaded_dev, intfs={1: {"Name": "Management", "Enabled": "Up", "Status": "Up"}}), + ] + ) + self.solarwinds.load_ipassignment.assert_has_calls( + [ + call( + addr="172.16.5.2", + dev_name="Router01", + intf_name="Management", + addr_type="IPv4", + mgmt_addr="172.16.5.2", + ), + call( + addr="10.11.1.1", + dev_name="Router01", + intf_name="TenGigabitEthernet1/1/1", + addr_type="IPv4", + mgmt_addr="172.16.5.2", + ), + call( + addr="10.11.1.2", + dev_name="Router01", + intf_name="TenGigabitEthernet1/1/2", + addr_type="IPv4", + mgmt_addr="172.16.5.2", + ), + call( + addr="172.16.1.1", + dev_name="Router01", + intf_name="Ethernet0/1", + addr_type="IPv4", + mgmt_addr="172.16.5.2", + ), + ] + ) + self.job.logger.error.assert_has_calls( + [ + call("UNKNOWN_DEVICE_TYPE1 is missing DeviceType so won't be imported."), + call("net-snmp Device is showing as net-snmp so won't be imported."), + ] + ) + self.assertEqual(len(self.solarwinds.failed_devices), 2) + self.job.logger.warning.assert_called_with( + 'List of 2 devices that were unable to be loaded. [\n {\n "NodeHostname": "UNKNOWN_DEVICE_TYPE1",\n "NodeID": 10,\n "interfaces": {\n "TenGigabitEthernet0/0/0": {\n "Name": "TenGigabitEthernet0/0/0",\n "Enabled": "Up",\n "Status": "Up",\n "TypeName": "ethernetCsmacd",\n "Speed": 10000000000.0,\n "MAC": "AA74D2BCD341",\n "MTU": 9104\n },\n "TenGigabitEthernet0/1/0": {\n "Name": "TenGigabitEthernet0/1/0",\n "Enabled": "Unknown",\n "Status": "Unknown",\n "TypeName": "ethernetCsmacd",\n "Speed": 10000000000.0,\n "MAC": "B8D028D78C15",\n "MTU": 9216\n },\n "TenGigabitEthernet0/1/0.75": {\n "Name": "TenGigabitEthernet0/1/0.75",\n "Enabled": "Unknown",\n "Status": "Unknown",\n "TypeName": "l2vlan",\n "Speed": 10000000000.0,\n "MAC": "G6F260AD2C18",\n "MTU": 9216\n }\n },\n "ipaddrs": {\n "1.1.1.1": {\n "IPAddress": "1.1.1.1",\n "SubnetMask": 23,\n "IPAddressType": "IPv4",\n "IntfName": "TenGigabitEthernet0/0/0"\n },\n "10.10.1.2": {\n "IPAddress": "10.10.1.2",\n "SubnetMask": 23,\n "IPAddressType": "IPv4",\n "IntfName": "TenGigabitEthernet0/1/0.75"\n }\n },\n "error": "Unable to determine DeviceType."\n },\n {\n "NodeHostname": "net-snmp Device",\n "NodeID": 12,\n "Vendor": "net-snmp",\n "error": "Unable to determine DeviceType."\n }\n]' + ) + + def test_data_loading_w_parent(self): + """Test Nautobot SSoT Solarwinds load() function with parent specified.""" + self.solarwinds = SolarwindsAdapter( + job=self.job, + sync=None, + client=self.solarwinds_client, + containers=self.containers, + location_type=self.location_type, + parent=self.parent, + ) + + self.solarwinds.load_parent = MagicMock() + self.solarwinds.get_container_nodes = MagicMock() + + self.solarwinds.load() + self.solarwinds.load_parent.assert_called_once() + self.solarwinds.get_container_nodes.assert_called_once() + + def test_load_manufacturer_and_device_type(self): + """Test the load_manufacturer_and_device_type() function for success.""" + self.solarwinds.load_manufacturer_and_device_type(manufacturer="Cisco", device_type="ASR1001") + self.assertEqual({"Cisco"}, {manu.get_unique_id() for manu in self.solarwinds.get_all("manufacturer")}) + self.assertEqual({"ASR1001__Cisco"}, {manu.get_unique_id() for manu in self.solarwinds.get_all("device_type")}) + + def test_get_nodes_custom_property(self): + """Test the get_nodes_custom_property() function success.""" + results = self.solarwinds_client.get_nodes_custom_property(custom_property="Nautobot_Monitoring") + self.solarwinds_client.get_nodes_custom_property.assert_called_once_with(custom_property="Nautobot_Monitoring") + self.solarwinds_client.get_nodes_custom_property.assert_called() + + self.assertEqual(results, fix.GET_NODES_CUSTOM_PROPERTY_FIXTURE) + + def test_get_container_nodes_specific_container(self): + """Test the get_container_nodes() function success with a specific container.""" + results = self.solarwinds.get_container_nodes() + self.assertEqual(self.solarwinds.containers, "HQ") + self.solarwinds_client.get_filtered_container_ids.assert_called_once_with(containers="HQ") + self.solarwinds_client.get_container_nodes.assert_called() + self.assertEqual(results, fix.GET_CONTAINER_NODES_FIXTURE) + + def test_get_container_nodes_all_containers(self): + """Test the get_container_nodes() function success with all containers.""" + self.solarwinds.containers = "ALL" + self.job.top_container = "USA" + results = self.solarwinds.get_container_nodes() + self.solarwinds_client.get_top_level_containers.assert_called_once_with(top_container="USA") + self.solarwinds_client.get_container_nodes.assert_called() + self.assertEqual(results, fix.GET_CONTAINER_NODES_FIXTURE) + + def test_load_location(self): + """Test the load_location() function.""" + self.solarwinds.load_location(loc_name="HQ", location_type="Site", status="Active") + self.assertEqual( + {"HQ__Site__None__None__None__None"}, {loc.get_unique_id() for loc in self.solarwinds.get_all("location")} + ) + + def test_load_parent(self): + """Test the load_parent() function loads the Parent Location.""" + self.solarwinds = SolarwindsAdapter( + job=self.job, + sync=None, + client=self.solarwinds_client, + containers=self.containers, + location_type=self.location_type, + parent=self.parent, + ) + self.solarwinds.load_parent() + self.assertEqual( + {"USA__Region__None__None__None__None"}, + {loc.get_unique_id() for loc in self.solarwinds.get_all("location")}, + ) + parent = self.solarwinds.get("location", "USA__Region__None__None__None__None") + self.assertEqual(parent.model_flags, DiffSyncModelFlags.SKIP_UNMATCHED_DST) + + def load_sites_wo_parent(self): + """Test the load_sites() function when a parent isn't specified.""" + test_sites = { + "HQ": [ + {"ContainerID": 1, "MemberPrimaryID": 10}, + {"ContainerID": 1, "MemberPrimaryID": 11}, + ], + "DC01": [ + {"ContainerID": 2, "MemberPrimaryID": 20}, + {"ContainerID": 2, "MemberPrimaryID": 21}, + ], + } + self.solarwinds.load_sites(container_nodes=test_sites) + self.job.logger.debug.calls[0].assert_called_with("Found 2 nodes for HQ container.") + self.job.logger.debug.calls[1].assert_called_with("Found 2 nodes for DC01 container.") + self.assertEqual( + {"HQ__Site__None__None__None__None", "DC01__Site__None__None__None__None"}, + {loc.get_unique_id() for loc in self.solarwinds.get_all("location")}, + ) + + def load_sites_w_parent(self): + """Test the load_sites() function when a parent isn't specified.""" + self.solarwinds = SolarwindsAdapter( + job=self.job, + sync=None, + client=self.solarwinds_client, + containers=self.containers, + location_type=self.location_type, + parent=self.parent, + ) + test_sites = { + "HQ": [ + {"ContainerID": 1, "MemberPrimaryID": 10}, + {"ContainerID": 1, "MemberPrimaryID": 11}, + ], + "DC01": [ + {"ContainerID": 2, "MemberPrimaryID": 20}, + {"ContainerID": 2, "MemberPrimaryID": 21}, + ], + } + self.solarwinds.load_sites(container_nodes=test_sites) + self.job.logger.debug.calls[0].assert_called_with("Found 2 nodes for HQ container.") + self.job.logger.debug.calls[1].assert_called_with("Found 2 nodes for DC01 container.") + self.assertEqual( + {"HQ__Site__USA__Region", "DC01__Site__USA__Region"}, + {loc.get_unique_id() for loc in self.solarwinds.get_all("location")}, + ) + + @patch("nautobot_ssot.integrations.solarwinds.diffsync.adapters.solarwinds.determine_role_from_devicetype") + def test_determine_device_role_device_type(self, mock_func): + """Test the determine_device_role() when DeviceType role choice is specified.""" + self.job.role_map = {"ASR1001": "Router"} + self.job.role_choice = "DeviceType" + + self.solarwinds.determine_device_role(node={}, device_type="ASR1001") + mock_func.assert_called_with(device_type="ASR1001", role_map={"ASR1001": "Router"}) + + @patch("nautobot_ssot.integrations.solarwinds.diffsync.adapters.solarwinds.determine_role_from_hostname") + def test_determine_device_role_hostname(self, mock_func): + """Test the determine_device_role() when Hostname role choice is specified.""" + self.job.role_map = {".*router.*": "Router"} + self.job.role_choice = "Hostname" + + self.solarwinds.determine_device_role(node={"NodeHostname": "core-router.corp"}, device_type="") + mock_func.assert_called_with(hostname="core-router.corp", role_map={".*router.*": "Router"}) + + def test_load_role(self): + """Test the load_role() success.""" + self.solarwinds.load_role(role="Test") + self.assertEqual({"Test"}, {role.get_unique_id() for role in self.solarwinds.get_all("role")}) + + def test_load_platform_ios(self): + """Test the load_platform() function with IOS device.""" + result = self.solarwinds.load_platform(device_type="ASR1001", manufacturer="Cisco") + self.assertEqual(result, "cisco.ios.ios") + self.assertEqual( + {"cisco.ios.ios__Cisco"}, {plat.get_unique_id() for plat in self.solarwinds.get_all("platform")} + ) + + def test_load_platform_nxos(self): + """Test the load_platform() function with Nexus device.""" + result = self.solarwinds.load_platform(device_type="N9K-93180YC", manufacturer="Cisco") + self.assertEqual(result, "cisco.nxos.nxos") + self.assertEqual( + {"cisco.nxos.nxos__Cisco"}, {plat.get_unique_id() for plat in self.solarwinds.get_all("platform")} + ) + + def test_load_interfaces(self): + """Test the load_interfaces() functions successfully.""" + mock_dev = MagicMock() + mock_dev.name = "Test Device" + + self.solarwinds_client.determine_interface_type.return_value = "1000base-t" + + test_intfs = { + "GigabitEthernet0/1": { + "Name": "GigabitEthernet0/1", + "Enabled": "Up", + "Status": "Up", + "MTU": 9180, + "MAC": "112233445566", + }, + "GigabitEthernet0/2": { + "Name": "GigabitEthernet0/2", + "Enabled": "Up", + "Status": "Up", + "MTU": 9180, + "MAC": "112233445567", + }, + } + self.solarwinds.load_interfaces(device=mock_dev, intfs=test_intfs) + self.assertEqual( + {"GigabitEthernet0/1__Test Device", "GigabitEthernet0/2__Test Device"}, + {intf.get_unique_id() for intf in self.solarwinds.get_all("interface")}, + ) + self.solarwinds_client.determine_interface_type.assert_called() + mock_dev.add_child.assert_called() + + def test_load_prefix(self): + """Validate that the load_prefix() function loads Prefix DiffSync object.""" + self.solarwinds.load_prefix(network="10.0.0.0/24") + self.assertEqual({"10.0.0.0__24__Global"}, {pf.get_unique_id() for pf in self.solarwinds.get_all("prefix")}) + + def test_load_ipaddress(self): + """Validate that load_ipaddress() correctly loads a DiffSync object.""" + self.solarwinds.load_ipaddress(addr="10.0.0.1", prefix_length=24, prefix="10.0.0.0/24", addr_type="IPv4") + self.assertEqual( + {"10.0.0.1__10.0.0.0__24__Global"}, + {ipaddr.get_unique_id() for ipaddr in self.solarwinds.get_all("ipaddress")}, + ) + + def test_load_ipassignment(self): + """Validate that load_ipassignment() correctly loads a DiffSync object.""" + self.solarwinds.load_ipassignment( + addr="10.0.0.1", dev_name="Test Device", intf_name="Management", addr_type="IPv4", mgmt_addr="10.0.0.1" + ) + self.assertEqual( + {"Test Device__Management__10.0.0.1"}, + {assignment.get_unique_id() for assignment in self.solarwinds.get_all("ipassignment")}, + ) + + def test_reprocess_ip_parent_prefixes_more_specific(self): + """Validate that reprocess_ip_parent_prefixes identifies a more specific prefix.""" + self.solarwinds.load_prefix(network="10.0.0.0/24") + self.solarwinds.load_prefix(network="10.0.0.0/25") + self.solarwinds.load_ipaddress(addr="10.0.0.1", prefix_length=24, prefix="10.0.0.0/24", addr_type="IPv4") + self.solarwinds.reprocess_ip_parent_prefixes() + self.job.debug = True + self.job.logger.debug.assert_called_once_with( + "More specific subnet %s found for IP %s/%s", "10.0.0.0/25", "10.0.0.1", 24 + ) + self.assertEqual( + {"10.0.0.1__10.0.0.0__25__Global"}, + {ipaddr.get_unique_id() for ipaddr in self.solarwinds.get_all("ipaddress")}, + ) + + def test_reprocess_ip_parent_prefixes_no_update(self): + """Validate that reprocess_ip_parent_prefixes does not update the ip.""" + self.solarwinds.load_prefix(network="10.0.0.0/24") + self.solarwinds.load_prefix(network="10.0.0.0/23") + self.solarwinds.load_ipaddress(addr="10.0.0.1", prefix_length=24, prefix="10.0.0.0/24", addr_type="IPv4") + self.solarwinds.reprocess_ip_parent_prefixes() + self.job.debug = True + self.job.logger.debug.assert_not_called() + self.assertEqual( + {"10.0.0.1__10.0.0.0__24__Global"}, + {ipaddr.get_unique_id() for ipaddr in self.solarwinds.get_all("ipaddress")}, + ) diff --git a/nautobot_ssot/tests/solarwinds/test_utils_solarwinds.py b/nautobot_ssot/tests/solarwinds/test_utils_solarwinds.py new file mode 100644 index 000000000..d23c5a464 --- /dev/null +++ b/nautobot_ssot/tests/solarwinds/test_utils_solarwinds.py @@ -0,0 +1,559 @@ +# pylint: disable=R0801 +"""Test Solarwinds utility functions and client.""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock, patch + +import requests +from nautobot.core.testing import TransactionTestCase +from nautobot.extras.models import JobResult +from parameterized import parameterized + +from nautobot_ssot.integrations.solarwinds.jobs import SolarwindsDataSource +from nautobot_ssot.integrations.solarwinds.utils.solarwinds import ( + determine_role_from_devicetype, + determine_role_from_hostname, +) +from nautobot_ssot.tests.solarwinds.conftest import create_solarwinds_client + + +class TestSolarwindsClientTestCase(TransactionTestCase): # pylint: disable=too-many-public-methods + """Test the SolarwindsClient class.""" + + databases = ("default", "job_logs") + + def setUp(self): + """Configure shared variables for tests.""" + self.job = SolarwindsDataSource() + self.job.job_result = JobResult.objects.create( + name=self.job.class_path, task_name="Fake task", user=None, id=uuid.uuid4() + ) + self.job.integration = MagicMock() + self.job.integration.extra_config = {"batch_size": 10} + self.job.logger.debug = MagicMock() + self.job.logger.error = MagicMock() + self.job.logger.info = MagicMock() + self.job.logger.warning = MagicMock() + self.test_client = create_solarwinds_client(job=self.job) + + self.test_nodes = [{"Name": "Router01", "MemberPrimaryID": 1}, {"Name": "Switch01", "MemberPrimaryID": 2}] + self.node_details = {1: {"NodeHostname": "Router01", "NodeID": 1}, 2: {"NodeHostname": "Switch01", "NodeID": 2}} + + def test_solarwinds_client_initialization(self): + """Validate the SolarwindsClient functionality.""" + self.assertEqual(self.test_client.url, "https://test.solarwinds.com:443/SolarWinds/InformationService/v3/Json/") + self.assertEqual(self.test_client.job, self.job) + self.assertEqual(self.test_client.batch_size, 10) + self.assertEqual(self.test_client.timeout, 60) + self.assertEqual(self.test_client.retries, 5) + + def test_query(self): + """Validate that query() works as expected.""" + mock_expected = MagicMock(spec=requests.Response) + mock_expected.status_code = 200 + mock_expected.json.return_value = {"results": {"1": {"Name": "HQ"}}} + self.test_client._req = MagicMock() # pylint: disable=protected-access + self.test_client._req.return_value = mock_expected # pylint: disable=protected-access + result = self.test_client.query(query="SELECT ContainerID FROM Orion.Container WHERE Name = 'HQ'") + self.test_client._req.assert_called_with( # pylint: disable=protected-access + "POST", "Query", {"query": "SELECT ContainerID FROM Orion.Container WHERE Name = 'HQ'", "parameters": {}} + ) + self.assertEqual(result, {"results": {"1": {"Name": "HQ"}}}) + + def test_json_serial(self): + """Validate the _json_serial() functionality.""" + test_datetime = datetime(2020, 1, 1, 12, 0, 0) + expected_serialized = "2020-01-01T12:00:00" + result = self.test_client._json_serial(test_datetime) # pylint: disable=protected-access + self.assertEqual(expected_serialized, result) + + @patch("nautobot_ssot.integrations.solarwinds.utils.solarwinds.requests.Session.request") + def test_successful_request(self, mock_request): + """Validate successful functionality of the _req() function.""" + mock_response = MagicMock(requests.Response) + mock_response.status_code = 200 + mock_request.return_value = mock_response + + response = self.test_client._req("GET", "test") # pylint: disable=protected-access + + self.assertEqual(response.status_code, 200) + mock_request.assert_called_once_with("GET", self.test_client.url + "test", data="null", timeout=60) + + @patch("nautobot_ssot.integrations.solarwinds.utils.solarwinds.requests.Session.request") + def test_request_with_data(self, mock_request): + """Validate successful functionality of the _req() function with data passed.""" + mock_response = MagicMock(requests.Response) + mock_response.status_code = 201 + mock_request.return_value = mock_response + + response = self.test_client._req("POST", "create", data={"key": "value"}) # pylint: disable=protected-access + + self.assertEqual(response.status_code, 201) + mock_request.assert_called_once_with( + "POST", self.test_client.url + "create", data='{"key": "value"}', timeout=60 + ) + + @patch("nautobot_ssot.integrations.solarwinds.utils.solarwinds.requests.Session.request") + def test_request_400_600_status_code(self, mock_request): + """Validate handling of _req() call when 4xx or 5xx status code returned.""" + mock_response = MagicMock(requests.Response) + mock_response.status_code = 401 + mock_response.text = '{"Message": "Unauthorized"}' + mock_request.return_value = mock_response + + response = self.test_client._req("GET", "unauthorized") # pylint: disable=protected-access + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.reason, "Unauthorized") + self.assertIsInstance(response, requests.Response) + mock_request.assert_called_once_with("GET", self.test_client.url + "unauthorized", data="null", timeout=60) + + @patch("nautobot_ssot.integrations.solarwinds.utils.solarwinds.requests.Session.request") + def test_request_json_decoding_error_handling(self, mock_request): + """Validate handling of JSON decoding error in _req() call.""" + mock_response = MagicMock(requests.Response) + mock_response.status_code = 500 + mock_response.text = '{"key": "value"' + mock_request.return_value = mock_response + + response = self.test_client._req("GET", "decode_error") # pylint: disable=protected-access + + self.assertEqual(response.status_code, 500) + self.assertIsInstance(response, requests.Response) + mock_request.assert_called_once_with("GET", self.test_client.url + "decode_error", data="null", timeout=60) + + @patch("nautobot_ssot.integrations.solarwinds.utils.solarwinds.requests.Session.request") + def test_request_exception_handling(self, mock_request): + """Validate handling of Exception thrown in _req() call.""" + mock_request.side_effect = requests.exceptions.RequestException("Request timed out") + + response = self.test_client._req("GET", "timeout") # pylint: disable=protected-access + + self.job.logger.error.assert_called_with("An error occurred: Request timed out") + self.assertEqual(response.status_code, None) + self.assertIsInstance(response, requests.Response) + self.assertEqual(response.content, None) + mock_request.assert_called_once_with("GET", self.test_client.url + "timeout", data="null", timeout=60) + + def test_get_filtered_container_ids_success(self): + """Validate successful retrieval of container IDs with get_filtered_container_ids().""" + self.test_client.find_container_id_by_name = MagicMock() + self.test_client.find_container_id_by_name.side_effect = [1, 2] + + expected = {"DC01": 1, "DC02": 2} + result = self.test_client.get_filtered_container_ids(containers="DC01,DC02") + self.assertEqual(result, expected) + self.job.logger.error.assert_not_called() + + def test_get_filtered_container_ids_failure(self): + """Validate failed retrieval of container IDs with get_filtered_container_ids().""" + self.test_client.find_container_id_by_name = MagicMock() + self.test_client.find_container_id_by_name.return_value = -1 + + result = self.test_client.get_filtered_container_ids(containers="Failure") + self.job.logger.error.assert_called_once_with("Unable to find container Failure.") + self.assertEqual(result, {}) + + def test_get_container_nodes(self): + """Validate functionality of get_container_nodes().""" + container_ids = {"DC01": 1} + self.test_client.recurse_collect_container_nodes = MagicMock() + self.test_client.recurse_collect_container_nodes.return_value = [1, 2, 3] + result = self.test_client.get_container_nodes(container_ids=container_ids) + + self.job.logger.debug.assert_called_once_with("Gathering container nodes for DC01 CID: 1.") + self.test_client.recurse_collect_container_nodes.assert_called_once() + self.assertEqual(result, {"DC01": [1, 2, 3]}) + + def test_get_top_level_containers(self): + """Validate functionality of get_top_level_containers().""" + self.test_client.find_container_id_by_name = MagicMock() + self.test_client.find_container_id_by_name.return_value = 1 + self.test_client.query = MagicMock() + self.test_client.query.return_value = { + "results": [ + {"ContainerID": 1, "Name": "Test", "MemberPrimaryID": 10}, + {"ContainerID": 1, "Name": "Test2", "MemberPrimaryID": 11}, + ] + } + + result = self.test_client.get_top_level_containers(top_container="Top") + self.assertEqual(result, {"Test": 10, "Test2": 11}) + self.test_client.find_container_id_by_name.assert_called_once_with(container_name="Top") + + def test_recurse_collect_container_nodes(self): + """Validate functionality of recurse_collect_container_nodes() finding Orion.Nodes EntityType.""" + + self.test_client.query = MagicMock() + self.test_client.query.side_effect = [ + { + "results": [ + {"Name": "Room01", "MemberEntityType": "Orion.Groups", "MemberPrimaryID": 20}, + {"Name": "DistroSwitch01", "MemberEntityType": "Orion.Nodes", "MemberPrimaryID": 21}, + ] + }, + {"results": [{"Name": "Room01-Router", "MemberEntityType": "Orion.Nodes", "MemberPrimaryID": 30}]}, + ] + + result = self.test_client.recurse_collect_container_nodes(current_container_id=1) + + self.job.logger.debug.assert_called_once_with("Exploring container: Room01 CID: 20") + self.assertEqual( + result, + [ + {"Name": "Room01-Router", "MemberEntityType": "Orion.Nodes", "MemberPrimaryID": 30}, + {"Name": "DistroSwitch01", "MemberEntityType": "Orion.Nodes", "MemberPrimaryID": 21}, + ], + ) + + def test_find_container_id_by_name_success(self): + """Validate successful functionality of find_container_id_by_name() finding container ID by name.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": [{"ContainerID": 1}]} + results = self.test_client.find_container_id_by_name(container_name="Test") + self.assertEqual(results, 1) + self.test_client.query.assert_called_once_with("SELECT ContainerID FROM Orion.Container WHERE Name = 'Test'") + + def test_find_container_id_by_name_failure(self): + """Validate failure functionality of find_container_id_by_name() finding container ID by name.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": []} + results = self.test_client.find_container_id_by_name(container_name="Test") + self.assertEqual(results, -1) + + def test_build_node_details(self): + """Validate functionality of build_node_details().""" + self.test_client.batch_fill_node_details = MagicMock() + self.test_client.get_node_prefix_length = MagicMock() + self.test_client.gather_interface_data = MagicMock() + self.test_client.gather_ipaddress_data = MagicMock() + result = self.test_client.build_node_details(nodes=self.test_nodes) + + self.test_client.batch_fill_node_details.assert_called_once_with( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.test_client.get_node_prefix_length.assert_called_once_with( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.info.assert_called_once_with("Loading interface details for nodes.") + self.test_client.gather_interface_data.assert_called_once_with( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.test_client.gather_ipaddress_data.assert_called_once_with( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.assertEqual(result, self.node_details) + + def test_batch_fill_node_details_success(self): + """Validate successful functionality of batch_fill_node_details() to fill in node details.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = { + "results": [ + { + "NodeID": 1, + "Version": "v1", + "IPAddress": "192.168.1.1", + "SNMPLocation": "", + "Vendor": "Cisco", + "DeviceType": "Cisco Catalyst 3560-G24TS", + "Model": "WS-C3560G-24TS-S", + "ServiceTag": "", + } + ] + } + self.test_client.batch_fill_node_details( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.debug.assert_called_once_with("Processing batch 1 of 1 - Orion.Nodes.") + self.test_client.query.assert_called_once_with( + "\n SELECT IOSVersion AS Version,\n o.IPAddress,\n Location AS SNMPLocation,\n o.Vendor,\n MachineType AS DeviceType,\n h.Model,\n h.ServiceTag,\n o.NodeID\n FROM Orion.Nodes o LEFT JOIN Orion.HardwareHealth.HardwareInfo h ON o.NodeID = h.NodeID\n WHERE NodeID IN (\n '1','2')" + ) + self.assertEqual( + self.node_details, + { + 1: { + "NodeHostname": "Router01", + "NodeID": 1, + "Version": "v1", + "IPAddress": "192.168.1.1", + "SNMPLocation": "", + "Vendor": "Cisco", + "DeviceType": "Cisco Catalyst 3560-G24TS", + "Model": "WS-C3560G-24TS-S", + "ServiceTag": "", + "PFLength": 32, + }, + 2: {"NodeHostname": "Switch01", "NodeID": 2}, + }, + ) + + def test_batch_fill_node_details_failure(self): + """Validate functionality of batch_fill_node_details() when no information is returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": []} + self.test_client.batch_fill_node_details( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.error.assert_called_once_with("Error: No node details found for the batch of nodes") + + def test_get_node_prefix_length_success(self): + """Validate functionality of get_node_prefix_length() when data returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": [{"NodeID": 1, "PFLength": 32}]} + self.test_client.get_node_prefix_length( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.debug.assert_called_once_with("Processing batch 1 of 1 - IPAM.IPInfo.") + self.test_client.query.assert_called_once_with( + "SELECT i.CIDR AS PFLength, o.NodeID FROM Orion.Nodes o JOIN IPAM.IPInfo i ON o.IPAddressGUID = i.IPAddressN WHERE o.NodeID IN ('1','2')" + ) + self.assertEqual( + self.node_details, + { + 1: { + "NodeHostname": "Router01", + "NodeID": 1, + "PFLength": 32, + }, + 2: {"NodeHostname": "Switch01", "NodeID": 2}, + }, + ) + + def test_get_node_prefix_length_failure(self): + """Validate functionality of get_node_prefix_length() when no information is returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": []} + self.test_client.get_node_prefix_length( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.error.assert_called_once_with("Error: No node details found for the batch of nodes") + + def test_gather_interface_data_success(self): + """Validate functionality of gather_interface_data() when data is returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = { + "results": [ + { + "NodeID": 1, + "Name": "TenGigabitEthernet0/0/0", + "Enabled": "Up", + "Status": "Up", + "TypeName": "ethernetCsmacd", + "Speed": 10000000000.0, + "MAC": "DE68F1A6C467", + "MTU": 1500, + }, + { + "NodeID": 1, + "Name": "TenGigabitEthernet0/0/1", + "Enabled": "Up", + "Status": "Up", + "TypeName": "ethernetCsmacd", + "Speed": 10000000000.0, + "MAC": "DE68F1A6C468", + "MTU": 1500, + }, + ] + } + expected = { + 1: { + "NodeHostname": "Router01", + "NodeID": 1, + "interfaces": { + "TenGigabitEthernet0/0/0": { + "Name": "TenGigabitEthernet0/0/0", + "Enabled": "Up", + "Status": "Up", + "TypeName": "ethernetCsmacd", + "Speed": 10000000000.0, + "MAC": "DE68F1A6C467", + "MTU": 1500, + }, + "TenGigabitEthernet0/0/1": { + "Name": "TenGigabitEthernet0/0/1", + "Enabled": "Up", + "Status": "Up", + "TypeName": "ethernetCsmacd", + "Speed": 10000000000.0, + "MAC": "DE68F1A6C468", + "MTU": 1500, + }, + }, + }, + 2: {"NodeHostname": "Switch01", "NodeID": 2}, + } + self.test_client.gather_interface_data( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.test_client.query.assert_called_once_with( + "\n SELECT n.NodeID,\n sa.StatusName AS Enabled,\n so.StatusName AS Status,\n i.Name,\n i.MAC,\n i.Speed,\n i.TypeName,\n i.MTU\n FROM Orion.Nodes n JOIN Orion.NPM.Interfaces i ON n.NodeID = i.NodeID INNER JOIN Orion.StatusInfo sa ON i.AdminStatus = sa.StatusId INNER JOIN Orion.StatusInfo so ON i.OperStatus = so.StatusId\n WHERE n.NodeID IN (\n '1','2')" + ) + self.assertEqual(self.node_details, expected) + + def test_gather_interface_data_failure(self): + """Validate functionality of gather_interface_data() when no information is returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": []} + self.test_client.gather_interface_data( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.error.assert_called_once_with("Error: No node details found for the batch of nodes") + + node_types = [ + ( + "catalyst", + { + "Vendor": "Cisco", + "DeviceType": "Catalyst 9500-48Y4C", + "Model": "C9500-48Y4C", + }, + "WS-C9500-48Y4C", + ), + ( + "blank_model", + {"Vendor": "Cisco", "DeviceType": "Cisco Catalyst 3560CG-8PC-S", "Model": ""}, + "WS-C3560CG-8PC-S", + ), + ( + "space_model", + {"Vendor": "Cisco", "DeviceType": "Cisco Catalyst 4500X-32 SFP+ Switch", "Model": " "}, + "WS-C4500X-32 SFP+ Switch", + ), + ("both_blank", {"Vendor": "Cisco", "DeviceType": "", "Model": ""}, ""), + ] + + @parameterized.expand(node_types, skip_on_empty=True) + def test_standardize_device_type(self, name, sent, received): # pylint: disable=unused-argument + """Validate functionality of standardize_device_type().""" + result = self.test_client.standardize_device_type(node=sent) + self.assertEqual(result, received) + + intf_types = [ + ("standard_tengig", {"TypeName": "ethernetCsmacd", "Name": "TenGigabitEthernet0/0/0"}, "10gbase-t"), + ("ethernet_speed", {"TypeName": "ethernetCsmacd", "Name": "Ethernet0/0", "Speed": 100000000.0}, "100base-tx"), + ("virtual", {"TypeName": "propVirtual", "Name": "PortChannel10"}, "virtual"), + ] + + @parameterized.expand(intf_types, skip_on_empty=True) + def test_determine_interface_type(self, name, sent, received): # pylint: disable=unused-argument + """Validate functionality of determine_interface_type().""" + result = self.test_client.determine_interface_type(interface=sent) + self.assertEqual(result, received) + + def test_determine_interface_type_failure(self): + """Validate functionality of determine_interface_type() when can't determine type.""" + test_intf = {"TypeName": "ethernetCsmacd", "Name": "Management", "Speed": 1.0} + result = self.test_client.determine_interface_type(interface=test_intf) + self.assertEqual(result, "virtual") + self.job.logger.debug.assert_called_once_with("Unable to find Ethernet interface in map: Management") + + test_versions = [ + ("release_software", "17.6.5, RELEASE SOFTWARE (fc2)", "17.6.5"), + ("copyright_software", "4.2(2f), Copyright (c) 2008-2022, Cisco Systems, Inc.", "4.2(2f)"), + ("release_no_comma", "03.11.01.E RELEASE SOFTWARE (fc4)", "03.11.01.E"), + ("copyright_no_comma", "4.0(4b) Copyright (c) 2008-2019, Cisco Systems, Inc.", "4.0(4b)"), + ] + + @parameterized.expand(test_versions, skip_on_empty=True) + def test_extract_version(self, name, sent, received): # pylint: disable=unused-argument + """Validate functionality of the extract_version() method.""" + result = self.test_client.extract_version(version=sent) + self.assertEqual(result, received) + + def test_gather_ipaddress_data_success(self): + """Validate functionality of gather_ipaddress_data() when data is returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = { + "results": [ + { + "NodeID": 1, + "IPAddress": "10.0.0.1", + "IPAddressType": "IPv4", + "Name": "Ethernet0/1", + "SubnetMask": "", + }, + { + "NodeID": 1, + "IPAddress": "2001:db8:::", + "IPAddressType": "IPv6", + "Name": "Ethernet0/2", + "SubnetMask": 32, + }, + { + "NodeID": 2, + "IPAddress": "192.168.0.1", + "IPAddressType": "IPv4", + "Name": "GigabitEthernet0/1", + "SubnetMask": "255.255.255.0", + }, + ] + } + expected = { + 1: { + "NodeHostname": "Router01", + "NodeID": 1, + "ipaddrs": { + "10.0.0.1": { + "IPAddress": "10.0.0.1", + "SubnetMask": 32, + "IPAddressType": "IPv4", + "IntfName": "Ethernet0/1", + }, + "2001:db8:::": { + "IPAddress": "2001:db8:::", + "SubnetMask": 128, + "IPAddressType": "IPv6", + "IntfName": "Ethernet0/2", + }, + }, + }, + 2: { + "NodeHostname": "Switch01", + "NodeID": 2, + "ipaddrs": { + "192.168.0.1": { + "IPAddress": "192.168.0.1", + "SubnetMask": 24, + "IPAddressType": "IPv4", + "IntfName": "GigabitEthernet0/1", + } + }, + }, + } + self.test_client.gather_ipaddress_data( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.test_client.query.assert_called_once_with( + "\n SELECT NIPA.NodeID,\n NIPA.InterfaceIndex,\n NIPA.IPAddress,\n NIPA.IPAddressType,\n NPMI.Name,\n NIPA.SubnetMask\n FROM Orion.NodeIPAddresses NIPA INNER JOIN Orion.NPM.Interfaces NPMI ON NIPA.NodeID=NPMI.NodeID AND NIPA.InterfaceIndex=NPMI.InterfaceIndex INNER JOIN Orion.Nodes N ON NIPA.NodeID=N.NodeID\n WHERE NIPA.NodeID IN (\n '1','2')" + ) + self.assertEqual(self.node_details, expected) + + def test_gather_ipaddress_data_failure(self): + """Validate functionality of gather_ipaddress_data() when no information is returned.""" + self.test_client.query = MagicMock() + self.test_client.query.return_value = {"results": []} + self.test_client.gather_ipaddress_data( + node_data=self.test_nodes, node_details=self.node_details, nodes_per_batch=10 + ) + self.job.logger.error.assert_called_once_with("Error: No node details found for the batch of nodes") + + def test_determine_role_from_devicetype_success(self): + """Validate successful functionality of determine_role_from_devicetype().""" + result = determine_role_from_devicetype(device_type="ASR1001", role_map={"ASR1001": "Router"}) + self.assertEqual(result, "Router") + + def test_determine_role_from_devicetype_failure(self): + """Validate functionality of determine_role_from_devicetype() when match isn't found.""" + result = determine_role_from_devicetype(device_type="Cat3k", role_map={"ASR1001": "Router"}) + self.assertEqual(result, "") + + def test_determine_role_from_hostname_success(self): + """Validate successful functionality of determine_role_from_hostname().""" + result = determine_role_from_hostname(hostname="core-router.test.com", role_map={".*router.*": "Router"}) + self.assertEqual(result, "Router") + + def test_determine_role_from_hostname_failure(self): + """Validate functionality of determine_role_from_hostname() when match not found.""" + result = determine_role_from_hostname(hostname="distro-switch.test.com", role_map={".*router.*": "Router"}) + self.assertEqual(result, "") diff --git a/poetry.lock b/poetry.lock index c7cbc1cd0..562740430 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,14 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" -version = "2.4.3" +version = "2.4.4" description = "Happy Eyeballs for asyncio" optional = true python-versions = ">=3.8" files = [ - {file = "aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572"}, - {file = "aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586"}, + {file = "aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8"}, + {file = "aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745"}, ] [[package]] @@ -258,21 +258,18 @@ wrapt = [ [[package]] name = "asttokens" -version = "2.4.1" +version = "3.0.0" description = "Annotate AST trees with source code positions" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, - {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, + {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, + {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, ] -[package.dependencies] -six = ">=1.12.0" - [package.extras] -astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] -test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] +astroid = ["astroid (>=2,<4)"] +test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "astunparse" @@ -302,19 +299,19 @@ files = [ [[package]] name = "attrs" -version = "24.2.0" +version = "24.3.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, + {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, + {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, ] [package.extras] benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] @@ -473,13 +470,13 @@ zstd = ["zstandard (==0.22.0)"] [[package]] name = "certifi" -version = "2024.8.30" +version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, ] [[package]] @@ -563,127 +560,114 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.4.0" +version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] [package.dependencies] @@ -757,26 +741,26 @@ testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] [[package]] name = "cloudvision" -version = "1.21.1" -description = "A Python library for Arista's CloudVision APIs and Provisioning Action integrations." +version = "1.9.0" +description = "A Python library for Arista's CloudVision APIs." optional = true -python-versions = ">=3.7.0" +python-versions = "*" files = [ - {file = "cloudvision-1.21.1-py3-none-any.whl", hash = "sha256:961115a34c4ed59d5094a0a4e4a8f2fc6181e0d3dfb23d90464e7768debce3cd"}, - {file = "cloudvision-1.21.1.tar.gz", hash = "sha256:6d7abd15de30d974be1be15711e0e14c65c460362defe6d8bd9ff765021d1984"}, + {file = "cloudvision-1.9.0-py3-none-any.whl", hash = "sha256:8df5d7d9db9bc8b145658874fb88eac17181c7071d3befd864cde522d619fbc1"}, + {file = "cloudvision-1.9.0.tar.gz", hash = "sha256:1855e3c943b1f77c273529e0b29dcca9045733bb5d775baf785775e62391d09f"}, ] [package.dependencies] -cryptography = ">=42.0.4,<43.0.0" -grpcio = ">=1.53.0" +cryptography = ">=39.0.0" +grpcio = ">=1.46.0" msgpack = ">=1.0.3" -protobuf = ">=4.22.5,<5.0" +protobuf = ">=3.20.1,<4.0" requests = ">=2.20.1" +types-protobuf = ">=3.20.1,<4.0" +types-PyYAML = ">=6.0.7" +types-requests = ">=2.27.25" typing-extensions = ">=4.2.0" -[package.extras] -dev = ["black (==24.3.0)", "flake8 (==3.8.4)", "grpcio-tools (>=1.53.2)", "isort (==5.11.4)", "mypy (==0.981)", "mypy-protobuf (==3.2.0)", "numpy (==1.26.4)", "pytest (==7.1.2)", "pyyaml (==6.0.1)", "twine (==4.0.1)", "types-PyYAML (>=6.0.7)", "types-attrs (>=19.1.0)", "types-protobuf (>=3.20.4.6,<4.0)", "types-requests (>=2.27.25)", "types-setuptools (>=69.0.0.0)", "wheel (==0.38.4)"] - [[package]] name = "colorama" version = "0.4.6" @@ -962,43 +946,38 @@ dev = ["polib"] [[package]] name = "cryptography" -version = "42.0.8" +version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, - {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, - {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, - {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, - {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, - {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, - {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] [package.dependencies] @@ -1011,7 +990,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1124,13 +1103,13 @@ profile = ["gprof2dot (>=2022.7.29)"] [[package]] name = "django" -version = "4.2.16" +version = "4.2.17" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.8" files = [ - {file = "Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898"}, - {file = "Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad"}, + {file = "Django-4.2.17-py3-none-any.whl", hash = "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0"}, + {file = "Django-4.2.17.tar.gz", hash = "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc"}, ] [package.dependencies] @@ -1429,6 +1408,23 @@ Django = ">=3.2" [package.extras] tablib = ["tablib"] +[[package]] +name = "django-tables2" +version = "2.7.5" +description = "Table/data-grid framework for Django" +optional = false +python-versions = ">=3.9" +files = [ + {file = "django_tables2-2.7.5-py3-none-any.whl", hash = "sha256:d9338937797207ffb6f481be2125c5ec3a0bb1858d409c672cc25fc5d654cb22"}, + {file = "django_tables2-2.7.5.tar.gz", hash = "sha256:fb5dcaa09379cf3947598ec7e1bd5f26ed63aafdee3b23963446763bbeac37bf"}, +] + +[package.dependencies] +django = ">=4.2" + +[package.extras] +tablib = ["tablib"] + [[package]] name = "django-taggit" version = "5.0.1" @@ -1596,13 +1592,13 @@ sidecar = ["drf-spectacular-sidecar"] [[package]] name = "drf-spectacular-sidecar" -version = "2024.11.1" +version = "2024.12.1" description = "Serve self-contained distribution builds of Swagger UI and Redoc with Django" optional = false python-versions = ">=3.6" files = [ - {file = "drf_spectacular_sidecar-2024.11.1-py3-none-any.whl", hash = "sha256:e2efd49c5bd1a607fd5d120d9da58d78e587852db8220b8880282a849296ff83"}, - {file = "drf_spectacular_sidecar-2024.11.1.tar.gz", hash = "sha256:fcfccc72cbdbe41e93f8416fa0c712d14126b8d1629e65c09c07c8edea24aad0"}, + {file = "drf_spectacular_sidecar-2024.12.1-py3-none-any.whl", hash = "sha256:e30821d150d29294f3be2018aab31b55cd724158e9e690b51a215264751aa8c7"}, + {file = "drf_spectacular_sidecar-2024.12.1.tar.gz", hash = "sha256:6be31df38bcf95681224b6550faa9344ee6dd5360dcf2b44afcc3f7460385613"}, ] [package.dependencies] @@ -1655,13 +1651,13 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth [[package]] name = "fastjsonschema" -version = "2.20.0" +version = "2.21.1" description = "Fastest Python implementation of JSON schema" optional = true python-versions = "*" files = [ - {file = "fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a"}, - {file = "fastjsonschema-2.20.0.tar.gz", hash = "sha256:3d48fc5300ee96f5d116f10fe6f28d938e6008f59a6a025c2649475b87f76a23"}, + {file = "fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667"}, + {file = "fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4"}, ] [package.extras] @@ -1669,61 +1665,61 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc [[package]] name = "fonttools" -version = "4.55.0" +version = "4.55.3" description = "Tools to manipulate font files" optional = true python-versions = ">=3.8" files = [ - {file = "fonttools-4.55.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:51c029d4c0608a21a3d3d169dfc3fb776fde38f00b35ca11fdab63ba10a16f61"}, - {file = "fonttools-4.55.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bca35b4e411362feab28e576ea10f11268b1aeed883b9f22ed05675b1e06ac69"}, - {file = "fonttools-4.55.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ce4ba6981e10f7e0ccff6348e9775ce25ffadbee70c9fd1a3737e3e9f5fa74f"}, - {file = "fonttools-4.55.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31d00f9852a6051dac23294a4cf2df80ced85d1d173a61ba90a3d8f5abc63c60"}, - {file = "fonttools-4.55.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e198e494ca6e11f254bac37a680473a311a88cd40e58f9cc4dc4911dfb686ec6"}, - {file = "fonttools-4.55.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7208856f61770895e79732e1dcbe49d77bd5783adf73ae35f87fcc267df9db81"}, - {file = "fonttools-4.55.0-cp310-cp310-win32.whl", hash = "sha256:e7e6a352ff9e46e8ef8a3b1fe2c4478f8a553e1b5a479f2e899f9dc5f2055880"}, - {file = "fonttools-4.55.0-cp310-cp310-win_amd64.whl", hash = "sha256:636caaeefe586d7c84b5ee0734c1a5ab2dae619dc21c5cf336f304ddb8f6001b"}, - {file = "fonttools-4.55.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fa34aa175c91477485c44ddfbb51827d470011e558dfd5c7309eb31bef19ec51"}, - {file = "fonttools-4.55.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:37dbb3fdc2ef7302d3199fb12468481cbebaee849e4b04bc55b77c24e3c49189"}, - {file = "fonttools-4.55.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5263d8e7ef3c0ae87fbce7f3ec2f546dc898d44a337e95695af2cd5ea21a967"}, - {file = "fonttools-4.55.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f307f6b5bf9e86891213b293e538d292cd1677e06d9faaa4bf9c086ad5f132f6"}, - {file = "fonttools-4.55.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f0a4b52238e7b54f998d6a56b46a2c56b59c74d4f8a6747fb9d4042190f37cd3"}, - {file = "fonttools-4.55.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3e569711464f777a5d4ef522e781dc33f8095ab5efd7548958b36079a9f2f88c"}, - {file = "fonttools-4.55.0-cp311-cp311-win32.whl", hash = "sha256:2b3ab90ec0f7b76c983950ac601b58949f47aca14c3f21eed858b38d7ec42b05"}, - {file = "fonttools-4.55.0-cp311-cp311-win_amd64.whl", hash = "sha256:aa046f6a63bb2ad521004b2769095d4c9480c02c1efa7d7796b37826508980b6"}, - {file = "fonttools-4.55.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:838d2d8870f84fc785528a692e724f2379d5abd3fc9dad4d32f91cf99b41e4a7"}, - {file = "fonttools-4.55.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f46b863d74bab7bb0d395f3b68d3f52a03444964e67ce5c43ce43a75efce9246"}, - {file = "fonttools-4.55.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33b52a9cfe4e658e21b1f669f7309b4067910321757fec53802ca8f6eae96a5a"}, - {file = "fonttools-4.55.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:732a9a63d6ea4a81b1b25a1f2e5e143761b40c2e1b79bb2b68e4893f45139a40"}, - {file = "fonttools-4.55.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7dd91ac3fcb4c491bb4763b820bcab6c41c784111c24172616f02f4bc227c17d"}, - {file = "fonttools-4.55.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1f0e115281a32ff532118aa851ef497a1b7cda617f4621c1cdf81ace3e36fb0c"}, - {file = "fonttools-4.55.0-cp312-cp312-win32.whl", hash = "sha256:6c99b5205844f48a05cb58d4a8110a44d3038c67ed1d79eb733c4953c628b0f6"}, - {file = "fonttools-4.55.0-cp312-cp312-win_amd64.whl", hash = "sha256:f8c8c76037d05652510ae45be1cd8fb5dd2fd9afec92a25374ac82255993d57c"}, - {file = "fonttools-4.55.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8118dc571921dc9e4b288d9cb423ceaf886d195a2e5329cc427df82bba872cd9"}, - {file = "fonttools-4.55.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01124f2ca6c29fad4132d930da69158d3f49b2350e4a779e1efbe0e82bd63f6c"}, - {file = "fonttools-4.55.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81ffd58d2691f11f7c8438796e9f21c374828805d33e83ff4b76e4635633674c"}, - {file = "fonttools-4.55.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5435e5f1eb893c35c2bc2b9cd3c9596b0fcb0a59e7a14121562986dd4c47b8dd"}, - {file = "fonttools-4.55.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d12081729280c39d001edd0f4f06d696014c26e6e9a0a55488fabc37c28945e4"}, - {file = "fonttools-4.55.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7ad1f1b98ab6cb927ab924a38a8649f1ffd7525c75fe5b594f5dab17af70e18"}, - {file = "fonttools-4.55.0-cp313-cp313-win32.whl", hash = "sha256:abe62987c37630dca69a104266277216de1023cf570c1643bb3a19a9509e7a1b"}, - {file = "fonttools-4.55.0-cp313-cp313-win_amd64.whl", hash = "sha256:2863555ba90b573e4201feaf87a7e71ca3b97c05aa4d63548a4b69ea16c9e998"}, - {file = "fonttools-4.55.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:00f7cf55ad58a57ba421b6a40945b85ac7cc73094fb4949c41171d3619a3a47e"}, - {file = "fonttools-4.55.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f27526042efd6f67bfb0cc2f1610fa20364396f8b1fc5edb9f45bb815fb090b2"}, - {file = "fonttools-4.55.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e67974326af6a8879dc2a4ec63ab2910a1c1a9680ccd63e4a690950fceddbe"}, - {file = "fonttools-4.55.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61dc0a13451143c5e987dec5254d9d428f3c2789a549a7cf4f815b63b310c1cc"}, - {file = "fonttools-4.55.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e526b325a903868c62155a6a7e24df53f6ce4c5c3160214d8fe1be2c41b478"}, - {file = "fonttools-4.55.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b7ef9068a1297714e6fefe5932c33b058aa1d45a2b8be32a4c6dee602ae22b5c"}, - {file = "fonttools-4.55.0-cp38-cp38-win32.whl", hash = "sha256:55718e8071be35dff098976bc249fc243b58efa263768c611be17fe55975d40a"}, - {file = "fonttools-4.55.0-cp38-cp38-win_amd64.whl", hash = "sha256:553bd4f8cc327f310c20158e345e8174c8eed49937fb047a8bda51daf2c353c8"}, - {file = "fonttools-4.55.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f901cef813f7c318b77d1c5c14cf7403bae5cb977cede023e22ba4316f0a8f6"}, - {file = "fonttools-4.55.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8c9679fc0dd7e8a5351d321d8d29a498255e69387590a86b596a45659a39eb0d"}, - {file = "fonttools-4.55.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2820a8b632f3307ebb0bf57948511c2208e34a4939cf978333bc0a3f11f838"}, - {file = "fonttools-4.55.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23bbbb49bec613a32ed1b43df0f2b172313cee690c2509f1af8fdedcf0a17438"}, - {file = "fonttools-4.55.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a656652e1f5d55b9728937a7e7d509b73d23109cddd4e89ee4f49bde03b736c6"}, - {file = "fonttools-4.55.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f50a1f455902208486fbca47ce33054208a4e437b38da49d6721ce2fef732fcf"}, - {file = "fonttools-4.55.0-cp39-cp39-win32.whl", hash = "sha256:161d1ac54c73d82a3cded44202d0218ab007fde8cf194a23d3dd83f7177a2f03"}, - {file = "fonttools-4.55.0-cp39-cp39-win_amd64.whl", hash = "sha256:ca7fd6987c68414fece41c96836e945e1f320cda56fc96ffdc16e54a44ec57a2"}, - {file = "fonttools-4.55.0-py3-none-any.whl", hash = "sha256:12db5888cd4dd3fcc9f0ee60c6edd3c7e1fd44b7dd0f31381ea03df68f8a153f"}, - {file = "fonttools-4.55.0.tar.gz", hash = "sha256:7636acc6ab733572d5e7eec922b254ead611f1cdad17be3f0be7418e8bfaca71"}, + {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] @@ -1860,13 +1856,13 @@ dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "gitdb" -version = "4.0.11" +version = "4.0.12" description = "Git Object Database" optional = false python-versions = ">=3.7" files = [ - {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, - {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, + {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, + {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, ] [package.dependencies] @@ -1874,20 +1870,20 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.43" +version = "3.1.44" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, - {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, + {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, + {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" [package.extras] -doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] +doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] [[package]] @@ -2010,70 +2006,70 @@ colorama = ">=0.4" [[package]] name = "grpcio" -version = "1.68.0" +version = "1.69.0" description = "HTTP/2-based RPC framework" optional = true python-versions = ">=3.8" files = [ - {file = "grpcio-1.68.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:619b5d0f29f4f5351440e9343224c3e19912c21aeda44e0c49d0d147a8d01544"}, - {file = "grpcio-1.68.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:a59f5822f9459bed098ffbceb2713abbf7c6fd13f2b9243461da5c338d0cd6c3"}, - {file = "grpcio-1.68.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:c03d89df516128febc5a7e760d675b478ba25802447624edf7aa13b1e7b11e2a"}, - {file = "grpcio-1.68.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44bcbebb24363d587472089b89e2ea0ab2e2b4df0e4856ba4c0b087c82412121"}, - {file = "grpcio-1.68.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79f81b7fbfb136247b70465bd836fa1733043fdee539cd6031cb499e9608a110"}, - {file = "grpcio-1.68.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:88fb2925789cfe6daa20900260ef0a1d0a61283dfb2d2fffe6194396a354c618"}, - {file = "grpcio-1.68.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:99f06232b5c9138593ae6f2e355054318717d32a9c09cdc5a2885540835067a1"}, - {file = "grpcio-1.68.0-cp310-cp310-win32.whl", hash = "sha256:a6213d2f7a22c3c30a479fb5e249b6b7e648e17f364598ff64d08a5136fe488b"}, - {file = "grpcio-1.68.0-cp310-cp310-win_amd64.whl", hash = "sha256:15327ab81131ef9b94cb9f45b5bd98803a179c7c61205c8c0ac9aff9d6c4e82a"}, - {file = "grpcio-1.68.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:3b2b559beb2d433129441783e5f42e3be40a9e1a89ec906efabf26591c5cd415"}, - {file = "grpcio-1.68.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46541de8425a4d6829ac6c5d9b16c03c292105fe9ebf78cb1c31e8d242f9155"}, - {file = "grpcio-1.68.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c1245651f3c9ea92a2db4f95d37b7597db6b246d5892bca6ee8c0e90d76fb73c"}, - {file = "grpcio-1.68.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f1931c7aa85be0fa6cea6af388e576f3bf6baee9e5d481c586980c774debcb4"}, - {file = "grpcio-1.68.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b0ff09c81e3aded7a183bc6473639b46b6caa9c1901d6f5e2cba24b95e59e30"}, - {file = "grpcio-1.68.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8c73f9fbbaee1a132487e31585aa83987ddf626426d703ebcb9a528cf231c9b1"}, - {file = "grpcio-1.68.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6b2f98165ea2790ea159393a2246b56f580d24d7da0d0342c18a085299c40a75"}, - {file = "grpcio-1.68.0-cp311-cp311-win32.whl", hash = "sha256:e1e7ed311afb351ff0d0e583a66fcb39675be112d61e7cfd6c8269884a98afbc"}, - {file = "grpcio-1.68.0-cp311-cp311-win_amd64.whl", hash = "sha256:e0d2f68eaa0a755edd9a47d40e50dba6df2bceda66960dee1218da81a2834d27"}, - {file = "grpcio-1.68.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8af6137cc4ae8e421690d276e7627cfc726d4293f6607acf9ea7260bd8fc3d7d"}, - {file = "grpcio-1.68.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4028b8e9a3bff6f377698587d642e24bd221810c06579a18420a17688e421af7"}, - {file = "grpcio-1.68.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f60fa2adf281fd73ae3a50677572521edca34ba373a45b457b5ebe87c2d01e1d"}, - {file = "grpcio-1.68.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e18589e747c1e70b60fab6767ff99b2d0c359ea1db8a2cb524477f93cdbedf5b"}, - {file = "grpcio-1.68.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0d30f3fee9372796f54d3100b31ee70972eaadcc87314be369360248a3dcffe"}, - {file = "grpcio-1.68.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7e0a3e72c0e9a1acab77bef14a73a416630b7fd2cbd893c0a873edc47c42c8cd"}, - {file = "grpcio-1.68.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a831dcc343440969aaa812004685ed322cdb526cd197112d0db303b0da1e8659"}, - {file = "grpcio-1.68.0-cp312-cp312-win32.whl", hash = "sha256:5a180328e92b9a0050958ced34dddcb86fec5a8b332f5a229e353dafc16cd332"}, - {file = "grpcio-1.68.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bddd04a790b69f7a7385f6a112f46ea0b34c4746f361ebafe9ca0be567c78e9"}, - {file = "grpcio-1.68.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:fc05759ffbd7875e0ff2bd877be1438dfe97c9312bbc558c8284a9afa1d0f40e"}, - {file = "grpcio-1.68.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:15fa1fe25d365a13bc6d52fcac0e3ee1f9baebdde2c9b3b2425f8a4979fccea1"}, - {file = "grpcio-1.68.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:32a9cb4686eb2e89d97022ecb9e1606d132f85c444354c17a7dbde4a455e4a3b"}, - {file = "grpcio-1.68.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dba037ff8d284c8e7ea9a510c8ae0f5b016004f13c3648f72411c464b67ff2fb"}, - {file = "grpcio-1.68.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0efbbd849867e0e569af09e165363ade75cf84f5229b2698d53cf22c7a4f9e21"}, - {file = "grpcio-1.68.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:4e300e6978df0b65cc2d100c54e097c10dfc7018b9bd890bbbf08022d47f766d"}, - {file = "grpcio-1.68.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:6f9c7ad1a23e1047f827385f4713b5b8c6c7d325705be1dd3e31fb00dcb2f665"}, - {file = "grpcio-1.68.0-cp313-cp313-win32.whl", hash = "sha256:3ac7f10850fd0487fcce169c3c55509101c3bde2a3b454869639df2176b60a03"}, - {file = "grpcio-1.68.0-cp313-cp313-win_amd64.whl", hash = "sha256:afbf45a62ba85a720491bfe9b2642f8761ff348006f5ef67e4622621f116b04a"}, - {file = "grpcio-1.68.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:f8f695d9576ce836eab27ba7401c60acaf9ef6cf2f70dfe5462055ba3df02cc3"}, - {file = "grpcio-1.68.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9fe1b141cda52f2ca73e17d2d3c6a9f3f3a0c255c216b50ce616e9dca7e3441d"}, - {file = "grpcio-1.68.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:4df81d78fd1646bf94ced4fb4cd0a7fe2e91608089c522ef17bc7db26e64effd"}, - {file = "grpcio-1.68.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46a2d74d4dd8993151c6cd585594c082abe74112c8e4175ddda4106f2ceb022f"}, - {file = "grpcio-1.68.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17278d977746472698460c63abf333e1d806bd41f2224f90dbe9460101c9796"}, - {file = "grpcio-1.68.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:15377bce516b1c861c35e18eaa1c280692bf563264836cece693c0f169b48829"}, - {file = "grpcio-1.68.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc5f0a4f5904b8c25729a0498886b797feb817d1fd3812554ffa39551112c161"}, - {file = "grpcio-1.68.0-cp38-cp38-win32.whl", hash = "sha256:def1a60a111d24376e4b753db39705adbe9483ef4ca4761f825639d884d5da78"}, - {file = "grpcio-1.68.0-cp38-cp38-win_amd64.whl", hash = "sha256:55d3b52fd41ec5772a953612db4e70ae741a6d6ed640c4c89a64f017a1ac02b5"}, - {file = "grpcio-1.68.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:0d230852ba97654453d290e98d6aa61cb48fa5fafb474fb4c4298d8721809354"}, - {file = "grpcio-1.68.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:50992f214264e207e07222703c17d9cfdcc2c46ed5a1ea86843d440148ebbe10"}, - {file = "grpcio-1.68.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:14331e5c27ed3545360464a139ed279aa09db088f6e9502e95ad4bfa852bb116"}, - {file = "grpcio-1.68.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f84890b205692ea813653ece4ac9afa2139eae136e419231b0eec7c39fdbe4c2"}, - {file = "grpcio-1.68.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0cf343c6f4f6aa44863e13ec9ddfe299e0be68f87d68e777328bff785897b05"}, - {file = "grpcio-1.68.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fd2c2d47969daa0e27eadaf15c13b5e92605c5e5953d23c06d0b5239a2f176d3"}, - {file = "grpcio-1.68.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:18668e36e7f4045820f069997834e94e8275910b1f03e078a6020bd464cb2363"}, - {file = "grpcio-1.68.0-cp39-cp39-win32.whl", hash = "sha256:2af76ab7c427aaa26aa9187c3e3c42f38d3771f91a20f99657d992afada2294a"}, - {file = "grpcio-1.68.0-cp39-cp39-win_amd64.whl", hash = "sha256:e694b5928b7b33ca2d3b4d5f9bf8b5888906f181daff6b406f4938f3a997a490"}, - {file = "grpcio-1.68.0.tar.gz", hash = "sha256:7e7483d39b4a4fddb9906671e9ea21aaad4f031cdfc349fec76bdfa1e404543a"}, + {file = "grpcio-1.69.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:2060ca95a8db295ae828d0fc1c7f38fb26ccd5edf9aa51a0f44251f5da332e97"}, + {file = "grpcio-1.69.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:2e52e107261fd8fa8fa457fe44bfadb904ae869d87c1280bf60f93ecd3e79278"}, + {file = "grpcio-1.69.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:316463c0832d5fcdb5e35ff2826d9aa3f26758d29cdfb59a368c1d6c39615a11"}, + {file = "grpcio-1.69.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26c9a9c4ac917efab4704b18eed9082ed3b6ad19595f047e8173b5182fec0d5e"}, + {file = "grpcio-1.69.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90b3646ced2eae3a0599658eeccc5ba7f303bf51b82514c50715bdd2b109e5ec"}, + {file = "grpcio-1.69.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3b75aea7c6cb91b341c85e7c1d9db1e09e1dd630b0717f836be94971e015031e"}, + {file = "grpcio-1.69.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5cfd14175f9db33d4b74d63de87c64bb0ee29ce475ce3c00c01ad2a3dc2a9e51"}, + {file = "grpcio-1.69.0-cp310-cp310-win32.whl", hash = "sha256:9031069d36cb949205293cf0e243abd5e64d6c93e01b078c37921493a41b72dc"}, + {file = "grpcio-1.69.0-cp310-cp310-win_amd64.whl", hash = "sha256:cc89b6c29f3dccbe12d7a3b3f1b3999db4882ae076c1c1f6df231d55dbd767a5"}, + {file = "grpcio-1.69.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8de1b192c29b8ce45ee26a700044717bcbbd21c697fa1124d440548964328561"}, + {file = "grpcio-1.69.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:7e76accf38808f5c5c752b0ab3fd919eb14ff8fafb8db520ad1cc12afff74de6"}, + {file = "grpcio-1.69.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:d5658c3c2660417d82db51e168b277e0ff036d0b0f859fa7576c0ffd2aec1442"}, + {file = "grpcio-1.69.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5494d0e52bf77a2f7eb17c6da662886ca0a731e56c1c85b93505bece8dc6cf4c"}, + {file = "grpcio-1.69.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ed866f9edb574fd9be71bf64c954ce1b88fc93b2a4cbf94af221e9426eb14d6"}, + {file = "grpcio-1.69.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c5ba38aeac7a2fe353615c6b4213d1fbb3a3c34f86b4aaa8be08baaaee8cc56d"}, + {file = "grpcio-1.69.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f79e05f5bbf551c4057c227d1b041ace0e78462ac8128e2ad39ec58a382536d2"}, + {file = "grpcio-1.69.0-cp311-cp311-win32.whl", hash = "sha256:bf1f8be0da3fcdb2c1e9f374f3c2d043d606d69f425cd685110dd6d0d2d61258"}, + {file = "grpcio-1.69.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb9302afc3a0e4ba0b225cd651ef8e478bf0070cf11a529175caecd5ea2474e7"}, + {file = "grpcio-1.69.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fc18a4de8c33491ad6f70022af5c460b39611e39578a4d84de0fe92f12d5d47b"}, + {file = "grpcio-1.69.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:0f0270bd9ffbff6961fe1da487bdcd594407ad390cc7960e738725d4807b18c4"}, + {file = "grpcio-1.69.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:dc48f99cc05e0698e689b51a05933253c69a8c8559a47f605cff83801b03af0e"}, + {file = "grpcio-1.69.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e925954b18d41aeb5ae250262116d0970893b38232689c4240024e4333ac084"}, + {file = "grpcio-1.69.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87d222569273720366f68a99cb62e6194681eb763ee1d3b1005840678d4884f9"}, + {file = "grpcio-1.69.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b62b0f41e6e01a3e5082000b612064c87c93a49b05f7602fe1b7aa9fd5171a1d"}, + {file = "grpcio-1.69.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:db6f9fd2578dbe37db4b2994c94a1d9c93552ed77dca80e1657bb8a05b898b55"}, + {file = "grpcio-1.69.0-cp312-cp312-win32.whl", hash = "sha256:b192b81076073ed46f4b4dd612b8897d9a1e39d4eabd822e5da7b38497ed77e1"}, + {file = "grpcio-1.69.0-cp312-cp312-win_amd64.whl", hash = "sha256:1227ff7836f7b3a4ab04e5754f1d001fa52a730685d3dc894ed8bc262cc96c01"}, + {file = "grpcio-1.69.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:a78a06911d4081a24a1761d16215a08e9b6d4d29cdbb7e427e6c7e17b06bcc5d"}, + {file = "grpcio-1.69.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:dc5a351927d605b2721cbb46158e431dd49ce66ffbacb03e709dc07a491dde35"}, + {file = "grpcio-1.69.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:3629d8a8185f5139869a6a17865d03113a260e311e78fbe313f1a71603617589"}, + {file = "grpcio-1.69.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9a281878feeb9ae26db0622a19add03922a028d4db684658f16d546601a4870"}, + {file = "grpcio-1.69.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc614e895177ab7e4b70f154d1a7c97e152577ea101d76026d132b7aaba003b"}, + {file = "grpcio-1.69.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1ee76cd7e2e49cf9264f6812d8c9ac1b85dda0eaea063af07292400f9191750e"}, + {file = "grpcio-1.69.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0470fa911c503af59ec8bc4c82b371ee4303ececbbdc055f55ce48e38b20fd67"}, + {file = "grpcio-1.69.0-cp313-cp313-win32.whl", hash = "sha256:b650f34aceac8b2d08a4c8d7dc3e8a593f4d9e26d86751ebf74ebf5107d927de"}, + {file = "grpcio-1.69.0-cp313-cp313-win_amd64.whl", hash = "sha256:028337786f11fecb5d7b7fa660475a06aabf7e5e52b5ac2df47414878c0ce7ea"}, + {file = "grpcio-1.69.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:b7f693db593d6bf285e015d5538bf1c86cf9c60ed30b6f7da04a00ed052fe2f3"}, + {file = "grpcio-1.69.0-cp38-cp38-macosx_10_14_universal2.whl", hash = "sha256:8b94e83f66dbf6fd642415faca0608590bc5e8d30e2c012b31d7d1b91b1de2fd"}, + {file = "grpcio-1.69.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:b634851b92c090763dde61df0868c730376cdb73a91bcc821af56ae043b09596"}, + {file = "grpcio-1.69.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf5f680d3ed08c15330d7830d06bc65f58ca40c9999309517fd62880d70cb06e"}, + {file = "grpcio-1.69.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:200e48a6e7b00f804cf00a1c26292a5baa96507c7749e70a3ec10ca1a288936e"}, + {file = "grpcio-1.69.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:45a4704339b6e5b24b0e136dea9ad3815a94f30eb4f1e1d44c4ac484ef11d8dd"}, + {file = "grpcio-1.69.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:85d347cb8237751b23539981dbd2d9d8f6e9ff90082b427b13022b948eb6347a"}, + {file = "grpcio-1.69.0-cp38-cp38-win32.whl", hash = "sha256:60e5de105dc02832dc8f120056306d0ef80932bcf1c0e2b4ca3b676de6dc6505"}, + {file = "grpcio-1.69.0-cp38-cp38-win_amd64.whl", hash = "sha256:282f47d0928e40f25d007f24eb8fa051cb22551e3c74b8248bc9f9bea9c35fe0"}, + {file = "grpcio-1.69.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:dd034d68a2905464c49479b0c209c773737a4245d616234c79c975c7c90eca03"}, + {file = "grpcio-1.69.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:01f834732c22a130bdf3dc154d1053bdbc887eb3ccb7f3e6285cfbfc33d9d5cc"}, + {file = "grpcio-1.69.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:a7f4ed0dcf202a70fe661329f8874bc3775c14bb3911d020d07c82c766ce0eb1"}, + {file = "grpcio-1.69.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd7ea241b10bc5f0bb0f82c0d7896822b7ed122b3ab35c9851b440c1ccf81588"}, + {file = "grpcio-1.69.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f03dc9b4da4c0dc8a1db7a5420f575251d7319b7a839004d8916257ddbe4816"}, + {file = "grpcio-1.69.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ca71d73a270dff052fe4edf74fef142d6ddd1f84175d9ac4a14b7280572ac519"}, + {file = "grpcio-1.69.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ccbed100dc43704e94ccff9e07680b540d64e4cc89213ab2832b51b4f68a520"}, + {file = "grpcio-1.69.0-cp39-cp39-win32.whl", hash = "sha256:1514341def9c6ec4b7f0b9628be95f620f9d4b99331b7ef0a1845fd33d9b579c"}, + {file = "grpcio-1.69.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1fea55d26d647346acb0069b08dca70984101f2dc95066e003019207212e303"}, + {file = "grpcio-1.69.0.tar.gz", hash = "sha256:936fa44241b5379c5afc344e1260d467bee495747eaf478de825bab2791da6f5"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.68.0)"] +protobuf = ["grpcio-tools (>=1.69.0)"] [[package]] name = "h11" @@ -2380,13 +2376,13 @@ files = [ [[package]] name = "ipfabric" -version = "6.10.4" +version = "6.10.7" description = "Python package for interacting with IP Fabric" optional = true python-versions = "<4.0,>=3.8" files = [ - {file = "ipfabric-6.10.4-py3-none-any.whl", hash = "sha256:c948cd8c5554c6524afa6bb20a320759a14437169c5f736951d790e033f6175b"}, - {file = "ipfabric-6.10.4.tar.gz", hash = "sha256:b19cfa7bc327e005ec8cd8100dca1b0c0971681433103f70c5d5def50816d16b"}, + {file = "ipfabric-6.10.7-py3-none-any.whl", hash = "sha256:18674f503ad951e32c00abccd578c7f96f595e489d3041165dc6056bae05f1a2"}, + {file = "ipfabric-6.10.7.tar.gz", hash = "sha256:c54c4c683f70c4108bc6674afe7ccc42aab776856cc6ce0341e56d2c3100b416"}, ] [package.dependencies] @@ -2397,7 +2393,10 @@ httpx = ">=0.26,<0.28" importlib_resources = {version = ">=5.13,<6.0", markers = "python_version < \"3.9\""} macaddress = ">=2.0.2,<2.1.0" pydantic = ">=2.5.3,<3.0.0" -pydantic-extra-types = ">=2.3.0,<3.0.0" +pydantic-extra-types = [ + {version = ">=2.3.0,<=2.10.0", markers = "python_version < \"3.9\""}, + {version = ">=2.3.0,<3.0.0", markers = "python_version >= \"3.9\""}, +] pydantic-settings = ">=2.1.0,<3.0.0" python-dateutil = ">=2.8.2,<3.0.0" python-dotenv = ">=1.0,<2.0" @@ -2406,10 +2405,10 @@ typing-extensions = {version = ">=4.9.0,<5.0.0", markers = "python_version < \"3 [package.extras] all = ["jinja2 (>=3.1.4,<4.0.0)", "openpyxl (>=3.1.2,<4.0.0)", "pandas (>=2.0.0,<3.0.0)", "pandas (>=2.1.4,<3.0.0)", "python-json-logger (>=2.0.7,<3.0.0)", "pyyaml (>=6.0.1,<7.0.0)", "rich (>=13.7.0,<14.0.0)", "tabulate (>=0.8.9,<0.10.0)"] -cli = ["openpyxl (>=3.1.2,<4.0.0)", "rich (>=13.7.0,<14.0.0)"] -cve = ["openpyxl (>=3.1.2,<4.0.0)"] +cli = ["openpyxl (>=3.1.2,<4.0.0)", "pandas (>=2.0.0,<3.0.0)", "pandas (>=2.1.4,<3.0.0)", "rich (>=13.7.0,<14.0.0)"] +cve = ["openpyxl (>=3.1.2,<4.0.0)", "pandas (>=2.0.0,<3.0.0)", "pandas (>=2.1.4,<3.0.0)"] examples = ["openpyxl (>=3.1.2,<4.0.0)", "pandas (>=2.0.0,<3.0.0)", "pandas (>=2.1.4,<3.0.0)", "python-json-logger (>=2.0.7,<3.0.0)", "pyyaml (>=6.0.1,<7.0.0)", "rich (>=13.7.0,<14.0.0)", "tabulate (>=0.8.9,<0.10.0)"] -matrix = ["jinja2 (>=3.1.4,<4.0.0)", "openpyxl (>=3.1.2,<4.0.0)"] +matrix = ["jinja2 (>=3.1.4,<4.0.0)", "openpyxl (>=3.1.2,<4.0.0)", "pandas (>=2.0.0,<3.0.0)", "pandas (>=2.1.4,<3.0.0)"] pd = ["pandas (>=2.0.0,<3.0.0)", "pandas (>=2.1.4,<3.0.0)"] [[package]] @@ -2485,13 +2484,13 @@ testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, ] [package.dependencies] @@ -3396,18 +3395,18 @@ testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4, [[package]] name = "nautobot" -version = "2.3.11" +version = "2.3.16" description = "Source of truth and network automation platform." optional = false python-versions = "<3.13,>=3.8" files = [ - {file = "nautobot-2.3.11-py3-none-any.whl", hash = "sha256:f6ab9599a927904f5e956b1dc80c80dc53bdd44222c383bc073dd81f0b3bf727"}, - {file = "nautobot-2.3.11.tar.gz", hash = "sha256:d65e5b10612bfd7092469ddc76a19e5fe9f1eb37157ae09bba71b132bbc240e1"}, + {file = "nautobot-2.3.16-py3-none-any.whl", hash = "sha256:60a1043c97ca0c6575c01ee7b92d28da761843d449d6ad1f038ba2dafeefcaf3"}, + {file = "nautobot-2.3.16.tar.gz", hash = "sha256:92aed5dfbf457f52f47b96191103dd327981b0173bc8f813dc03a6c929cda45b"}, ] [package.dependencies] celery = ">=5.3.6,<5.4.0" -Django = ">=4.2.16,<4.3.0" +Django = ">=4.2.17,<4.3.0" django-ajax-tables = ">=1.1.1,<1.2.0" django-celery-beat = ">=2.6.0,<2.7.0" django-celery-results = ">=2.5.1,<2.6.0" @@ -3422,7 +3421,10 @@ django-prometheus = ">=2.3.1,<2.4.0" 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 = ">=2.7.0,<2.8.0" +django-tables2 = [ + {version = "2.7.0", markers = "python_version < \"3.9\""}, + {version = ">=2.7.4,<2.8.0", markers = "python_version >= \"3.9\""}, +] django-taggit = ">=5.0.0,<5.1.0" django-timezone-field = ">=7.0,<7.1" django-tree-queries = ">=0.19.0,<0.20.0" @@ -3434,14 +3436,14 @@ emoji = ">=2.12.1,<2.13.0" GitPython = ">=3.1.43,<3.2.0" graphene-django = ">=2.16.0,<2.17.0" graphene-django-optimizer = ">=0.8.0,<0.9.0" -Jinja2 = ">=3.1.4,<3.2.0" +Jinja2 = ">=3.1.5,<3.2.0" jsonschema = ">=4.7.0,<5.0.0" kombu = ">=5.4.2,<5.5.0" Markdown = ">=3.6,<3.7" MarkupSafe = ">=2.1.5,<2.2.0" netaddr = ">=1.3.0,<1.4.0" netutils = ">=1.6.0,<2.0.0" -nh3 = ">=0.2.15,<0.3.0" +nh3 = ">=0.2.20,<0.3.0" packaging = ">=23.1" Pillow = ">=10.3.0,<10.4.0" prometheus-client = ">=0.20.0,<0.21.0" @@ -3453,9 +3455,9 @@ social-auth-app-django = ">=5.4.2,<5.5.0" svgwrite = ">=1.4.2,<1.5.0" [package.extras] -all = ["django-auth-ldap (>=4.8.0,<4.9.0)", "django-storages (==1.14.3)", "mysqlclient (>=2.2.5,<2.3.0)", "napalm (>=4.1.0,<6.0.0)", "social-auth-core[saml] (>=4.5.3,<4.6.0)"] +all = ["django-auth-ldap (>=4.8.0,<4.9.0)", "django-storages (==1.14.3)", "mysqlclient (>=2.2.6,<2.3.0)", "napalm (>=4.1.0,<6.0.0)", "social-auth-core[saml] (>=4.5.3,<4.6.0)"] ldap = ["django-auth-ldap (>=4.8.0,<4.9.0)"] -mysql = ["mysqlclient (>=2.2.5,<2.3.0)"] +mysql = ["mysqlclient (>=2.2.6,<2.3.0)"] 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)"] @@ -3496,13 +3498,13 @@ nicer-shell = ["ipython"] [[package]] name = "netutils" -version = "1.10.0" +version = "1.11.0" description = "Common helper functions useful in network automation." optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "netutils-1.10.0-py3-none-any.whl", hash = "sha256:19b8cc3d2cf567a986f916c90f298d241af03a71c62ec6d38d6dc3395347670b"}, - {file = "netutils-1.10.0.tar.gz", hash = "sha256:f457fb85cb622e89aa0403fb2128c50986f7ce38d93a5873981727d088619793"}, + {file = "netutils-1.11.0-py3-none-any.whl", hash = "sha256:863674eb7dce2b85972d52079b4884fb30e498ccf1dd581abc28b4d69bfdf0cd"}, + {file = "netutils-1.11.0.tar.gz", hash = "sha256:1631152256db1623675d9087d4327b2f4633d294f758518742a974e868a50ae8"}, ] [package.extras] @@ -3510,27 +3512,35 @@ optionals = ["jsonschema (>=4.17.3,<5.0.0)", "napalm (>=4.0.0,<5.0.0)"] [[package]] name = "nh3" -version = "0.2.18" -description = "Python bindings to the ammonia HTML sanitization library." +version = "0.2.20" +description = "Python binding to Ammonia HTML sanitizer Rust crate" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86"}, - {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c64511469005058cd17cc1537578eac40ae9f7200bedcfd1fc1a05f4f8c200"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0411beb0589eacb6734f28d5497ca2ed379eafab8ad8c84b31bb5c34072b7164"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5f36b271dae35c465ef5e9090e1fdaba4a60a56f0bb0ba03e0932a66f28b9189"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34c03fa78e328c691f982b7c03d4423bdfd7da69cd707fe572f544cf74ac23ad"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19aaba96e0f795bd0a6c56291495ff59364f4300d4a39b29a0abc9cb3774a84b"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de3ceed6e661954871d6cd78b410213bdcb136f79aafe22aa7182e028b8c7307"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6955369e4d9f48f41e3f238a9e60f9410645db7e07435e62c6a9ea6135a4907f"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f0eca9ca8628dbb4e916ae2491d72957fdd35f7a5d326b7032a345f111ac07fe"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3a157ab149e591bb638a55c8c6bcb8cdb559c8b12c13a8affaba6cedfe51713a"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:c8b3a1cebcba9b3669ed1a84cc65bf005728d2f0bc1ed2a6594a992e817f3a50"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36c95d4b70530b320b365659bb5034341316e6a9b30f0b25fa9c9eff4c27a204"}, - {file = "nh3-0.2.18-cp37-abi3-win32.whl", hash = "sha256:a7f1b5b2c15866f2db413a3649a8fe4fd7b428ae58be2c0f6bca5eefd53ca2be"}, - {file = "nh3-0.2.18-cp37-abi3-win_amd64.whl", hash = "sha256:8ce0f819d2f1933953fca255db2471ad58184a60508f03e6285e5114b6254844"}, - {file = "nh3-0.2.18.tar.gz", hash = "sha256:94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4"}, + {file = "nh3-0.2.20-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e1061a4ab6681f6bdf72b110eea0c4e1379d57c9de937db3be4202f7ad6043db"}, + {file = "nh3-0.2.20-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb4254b1dac4a1ee49919a5b3f1caf9803ea8dada1816d9e8289e63d3cd0dd9a"}, + {file = "nh3-0.2.20-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ae9cbd713524cdb81e64663d0d6aae26f678db9f2cd9db0bf162606f1f9f20c"}, + {file = "nh3-0.2.20-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e1f7370b4e14cc03f5ae141ef30a1caf81fa5787711f80be9081418dd9eb79d2"}, + {file = "nh3-0.2.20-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ac4d27dc836a476efffc6eb661994426b8b805c951b29c9cf2ff36bc9ad58bc5"}, + {file = "nh3-0.2.20-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4fd2e9248725ebcedac3997a8d3da0d90a12a28c9179c6ba51f1658938ac30d0"}, + {file = "nh3-0.2.20-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f7d564871833ddbe54df3aa59053b1110729d3a800cb7628ae8f42adb3d75208"}, + {file = "nh3-0.2.20-cp313-cp313t-win32.whl", hash = "sha256:d2a176fd4306b6f0f178a3f67fac91bd97a3a8d8fafb771c9b9ef675ba5c8886"}, + {file = "nh3-0.2.20-cp313-cp313t-win_amd64.whl", hash = "sha256:6ed834c68452a600f517dd3e1534dbfaff1f67f98899fecf139a055a25d99150"}, + {file = "nh3-0.2.20-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:76e2f603b30c02ff6456b233a83fc377dedab6a50947b04e960a6b905637b776"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:181063c581defe683bd4bb78188ac9936d208aebbc74c7f7c16b6a32ae2ebb38"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:231addb7643c952cd6d71f1c8702d703f8fe34afcb20becb3efb319a501a12d7"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1b9a8340a0aab991c68a5ca938d35ef4a8a3f4bf1b455da8855a40bee1fa0ace"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10317cd96fe4bbd4eb6b95f3920b71c902157ad44fed103fdcde43e3b8ee8be6"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8698db4c04b140800d1a1cd3067fda399e36e1e2b8fc1fe04292a907350a3e9b"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eb04b9c3deb13c3a375ea39fd4a3c00d1f92e8fb2349f25f1e3e4506751774b"}, + {file = "nh3-0.2.20-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92f3f1c4f47a2c6f3ca7317b1d5ced05bd29556a75d3a4e2715652ae9d15c05d"}, + {file = "nh3-0.2.20-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ddefa9fd6794a87e37d05827d299d4b53a3ec6f23258101907b96029bfef138a"}, + {file = "nh3-0.2.20-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ce3731c8f217685d33d9268362e5b4f770914e922bba94d368ab244a59a6c397"}, + {file = "nh3-0.2.20-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:09f037c02fc2c43b211ff1523de32801dcfb0918648d8e651c36ef890f1731ec"}, + {file = "nh3-0.2.20-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:813f1c8012dd64c990514b795508abb90789334f76a561fa0fd4ca32d2275330"}, + {file = "nh3-0.2.20-cp38-abi3-win32.whl", hash = "sha256:47b2946c0e13057855209daeffb45dc910bd0c55daf10190bb0b4b60e2999784"}, + {file = "nh3-0.2.20-cp38-abi3-win_amd64.whl", hash = "sha256:da87573f03084edae8eb87cfe811ec338606288f81d333c07d2a9a0b9b976c0b"}, + {file = "nh3-0.2.20.tar.gz", hash = "sha256:9705c42d7ff88a0bea546c82d7fe5e59135e3d3f057e485394f491248a1f8ed5"}, ] [[package]] @@ -3645,6 +3655,20 @@ files = [ [package.extras] dev = ["black", "mypy", "pytest"] +[[package]] +name = "orionsdk" +version = "0.4.0" +description = "Python API for the SolarWinds Orion SDK" +optional = true +python-versions = "*" +files = [ + {file = "orionsdk-0.4.0.tar.gz", hash = "sha256:129ab44f15ee5c4d881715398854410e26efe18b1e5c59e6962231d793091165"}, +] + +[package.dependencies] +requests = "*" +six = "*" + [[package]] name = "packaging" version = "23.2" @@ -4001,22 +4025,33 @@ files = [ [[package]] name = "protobuf" -version = "4.25.5" -description = "" +version = "3.20.3" +description = "Protocol Buffers" optional = true -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "protobuf-4.25.5-cp310-abi3-win32.whl", hash = "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8"}, - {file = "protobuf-4.25.5-cp310-abi3-win_amd64.whl", hash = "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea"}, - {file = "protobuf-4.25.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173"}, - {file = "protobuf-4.25.5-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d"}, - {file = "protobuf-4.25.5-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331"}, - {file = "protobuf-4.25.5-cp38-cp38-win32.whl", hash = "sha256:98d8d8aa50de6a2747efd9cceba361c9034050ecce3e09136f90de37ddba66e1"}, - {file = "protobuf-4.25.5-cp38-cp38-win_amd64.whl", hash = "sha256:b0234dd5a03049e4ddd94b93400b67803c823cfc405689688f59b34e0742381a"}, - {file = "protobuf-4.25.5-cp39-cp39-win32.whl", hash = "sha256:abe32aad8561aa7cc94fc7ba4fdef646e576983edb94a73381b03c53728a626f"}, - {file = "protobuf-4.25.5-cp39-cp39-win_amd64.whl", hash = "sha256:7a183f592dc80aa7c8da7ad9e55091c4ffc9497b3054452d629bb85fa27c2a45"}, - {file = "protobuf-4.25.5-py3-none-any.whl", hash = "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41"}, - {file = "protobuf-4.25.5.tar.gz", hash = "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584"}, + {file = "protobuf-3.20.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99"}, + {file = "protobuf-3.20.3-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e"}, + {file = "protobuf-3.20.3-cp310-cp310-win32.whl", hash = "sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c"}, + {file = "protobuf-3.20.3-cp310-cp310-win_amd64.whl", hash = "sha256:67a3598f0a2dcbc58d02dd1928544e7d88f764b47d4a286202913f0b2801c2e7"}, + {file = "protobuf-3.20.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:899dc660cd599d7352d6f10d83c95df430a38b410c1b66b407a6b29265d66469"}, + {file = "protobuf-3.20.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e64857f395505ebf3d2569935506ae0dfc4a15cb80dc25261176c784662cdcc4"}, + {file = "protobuf-3.20.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d9e4432ff660d67d775c66ac42a67cf2453c27cb4d738fc22cb53b5d84c135d4"}, + {file = "protobuf-3.20.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:74480f79a023f90dc6e18febbf7b8bac7508420f2006fabd512013c0c238f454"}, + {file = "protobuf-3.20.3-cp37-cp37m-win32.whl", hash = "sha256:b6cc7ba72a8850621bfec987cb72623e703b7fe2b9127a161ce61e61558ad905"}, + {file = "protobuf-3.20.3-cp37-cp37m-win_amd64.whl", hash = "sha256:8c0c984a1b8fef4086329ff8dd19ac77576b384079247c770f29cc8ce3afa06c"}, + {file = "protobuf-3.20.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:de78575669dddf6099a8a0f46a27e82a1783c557ccc38ee620ed8cc96d3be7d7"}, + {file = "protobuf-3.20.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:f4c42102bc82a51108e449cbb32b19b180022941c727bac0cfd50170341f16ee"}, + {file = "protobuf-3.20.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44246bab5dd4b7fbd3c0c80b6f16686808fab0e4aca819ade6e8d294a29c7050"}, + {file = "protobuf-3.20.3-cp38-cp38-win32.whl", hash = "sha256:c02ce36ec760252242a33967d51c289fd0e1c0e6e5cc9397e2279177716add86"}, + {file = "protobuf-3.20.3-cp38-cp38-win_amd64.whl", hash = "sha256:447d43819997825d4e71bf5769d869b968ce96848b6479397e29fc24c4a5dfe9"}, + {file = "protobuf-3.20.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:398a9e0c3eaceb34ec1aee71894ca3299605fa8e761544934378bbc6c97de23b"}, + {file = "protobuf-3.20.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bf01b5720be110540be4286e791db73f84a2b721072a3711efff6c324cdf074b"}, + {file = "protobuf-3.20.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:daa564862dd0d39c00f8086f88700fdbe8bc717e993a21e90711acfed02f2402"}, + {file = "protobuf-3.20.3-cp39-cp39-win32.whl", hash = "sha256:819559cafa1a373b7096a482b504ae8a857c89593cf3a25af743ac9ecbd23480"}, + {file = "protobuf-3.20.3-cp39-cp39-win_amd64.whl", hash = "sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7"}, + {file = "protobuf-3.20.3-py2.py3-none-any.whl", hash = "sha256:a7ca6d488aa8ff7f329d4c545b2dbad8ac31464f1d8b1c87ad1346717731e4db"}, + {file = "protobuf-3.20.3.tar.gz", hash = "sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2"}, ] [[package]] @@ -4168,19 +4203,19 @@ files = [ [[package]] name = "pydantic" -version = "2.9.2" +version = "2.10.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, + {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, + {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.23.4" -typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} +pydantic-core = "2.27.2" +typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -4188,100 +4223,111 @@ timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.23.4" +version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, - {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, - {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, - {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, - {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, - {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, - {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, - {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, - {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, - {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, - {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, ] [package.dependencies] @@ -4310,15 +4356,38 @@ pycountry = ["pycountry (>=23)"] python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<3)"] semver = ["semver (>=3.0.2)"] +[[package]] +name = "pydantic-extra-types" +version = "2.10.1" +description = "Extra Pydantic types." +optional = true +python-versions = ">=3.8" +files = [ + {file = "pydantic_extra_types-2.10.1-py3-none-any.whl", hash = "sha256:db2c86c04a837bbac0d2d79bbd6f5d46c4c9253db11ca3fdd36a2b282575f1e2"}, + {file = "pydantic_extra_types-2.10.1.tar.gz", hash = "sha256:e4f937af34a754b8f1fa228a2fac867091a51f56ed0e8a61d5b3a6719b13c923"}, +] + +[package.dependencies] +pydantic = ">=2.5.2" +typing-extensions = "*" + +[package.extras] +all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23)", "python-ulid (>=1,<2)", "python-ulid (>=1,<4)", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)"] +pendulum = ["pendulum (>=3.0.0,<4.0.0)"] +phonenumbers = ["phonenumbers (>=8,<9)"] +pycountry = ["pycountry (>=23)"] +python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<4)"] +semver = ["semver (>=3.0.2)"] + [[package]] name = "pydantic-settings" -version = "2.6.1" +version = "2.7.1" description = "Settings management using Pydantic" optional = true python-versions = ">=3.8" files = [ - {file = "pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87"}, - {file = "pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0"}, + {file = "pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd"}, + {file = "pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93"}, ] [package.dependencies] @@ -4332,13 +4401,13 @@ yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] [package.extras] @@ -4441,13 +4510,13 @@ pylint = ">=1.7" [[package]] name = "pymdown-extensions" -version = "10.12" +version = "10.14" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77"}, - {file = "pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7"}, + {file = "pymdown_extensions-10.14-py3-none-any.whl", hash = "sha256:202481f716cc8250e4be8fce997781ebf7917701b59652458ee47f2401f818b5"}, + {file = "pymdown_extensions-10.14.tar.gz", hash = "sha256:741bd7c4ff961ba40b7528d32284c53bc436b8b1645e8e37c3e57770b8700a34"}, ] [package.dependencies] @@ -4455,7 +4524,7 @@ markdown = ">=3.6" pyyaml = "*" [package.extras] -extra = ["pygments (>=2.12)"] +extra = ["pygments (>=2.19.1)"] [[package]] name = "pyparsing" @@ -4742,13 +4811,13 @@ pyyaml = "*" [[package]] name = "redis" -version = "5.2.0" +version = "5.2.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" files = [ - {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, - {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, + {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, + {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, ] [package.dependencies] @@ -5167,13 +5236,13 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -5195,13 +5264,13 @@ pandas = ["pandas (>=2.2.2,<3.0.0)"] [[package]] name = "smmap" -version = "5.0.1" +version = "5.0.2" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" files = [ - {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, - {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, + {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, + {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, ] [[package]] @@ -5393,13 +5462,13 @@ test = ["pytest"] [[package]] name = "sqlparse" -version = "0.5.2" +version = "0.5.3" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" files = [ - {file = "sqlparse-0.5.2-py3-none-any.whl", hash = "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e"}, - {file = "sqlparse-0.5.2.tar.gz", hash = "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f"}, + {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, + {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, ] [package.extras] @@ -5491,13 +5560,43 @@ files = [ [[package]] name = "tomli" -version = "2.1.0" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, - {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] @@ -5548,6 +5647,42 @@ 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 = "types-protobuf" +version = "3.20.4.6" +description = "Typing stubs for protobuf" +optional = true +python-versions = "*" +files = [ + {file = "types-protobuf-3.20.4.6.tar.gz", hash = "sha256:ba27443c592bbec1629dd69494a24c84461c63f0d3b7d648ce258aaae9680965"}, + {file = "types_protobuf-3.20.4.6-py3-none-any.whl", hash = "sha256:ab2d315ba82246b83d28f8797c98dc0fe1dd5cfd187909e56faf87239aedaae3"}, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20241230" +description = "Typing stubs for PyYAML" +optional = true +python-versions = ">=3.8" +files = [ + {file = "types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6"}, + {file = "types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c"}, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +description = "Typing stubs for requests" +optional = true +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, + {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" version = "4.12.2" @@ -5669,13 +5804,13 @@ files = [ [[package]] name = "wheel" -version = "0.45.0" +version = "0.45.1" description = "A built-package format for Python" optional = false python-versions = ">=3.8" files = [ - {file = "wheel-0.45.0-py3-none-any.whl", hash = "sha256:52f0baa5e6522155090a09c6bd95718cc46956d1b51d537ea5454249edb671c7"}, - {file = "wheel-0.45.0.tar.gz", hash = "sha256:a57353941a3183b3d5365346b567a260a0602a0f8a635926a7dede41b94c674a"}, + {file = "wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248"}, + {file = "wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729"}, ] [package.extras] @@ -5683,81 +5818,76 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "wrapt" -version = "1.16.0" +version = "1.17.0" description = "Module for decorators, wrappers and monkey patching." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, + {file = "wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301"}, + {file = "wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22"}, + {file = "wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575"}, + {file = "wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b"}, + {file = "wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346"}, + {file = "wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a"}, + {file = "wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4"}, + {file = "wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635"}, + {file = "wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7"}, + {file = "wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a"}, + {file = "wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045"}, + {file = "wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838"}, + {file = "wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab"}, + {file = "wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf"}, + {file = "wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a"}, + {file = "wrapt-1.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f"}, + {file = "wrapt-1.17.0-cp38-cp38-win32.whl", hash = "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea"}, + {file = "wrapt-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed"}, + {file = "wrapt-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0"}, + {file = "wrapt-1.17.0-cp39-cp39-win32.whl", hash = "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88"}, + {file = "wrapt-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977"}, + {file = "wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371"}, + {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, ] [[package]] @@ -5911,7 +6041,7 @@ type = ["pytest-mypy"] [extras] aci = ["PyYAML"] -all = ["Jinja2", "PyYAML", "cloudvision", "cvprac", "dnacentersdk", "dnspython", "ijson", "ipfabric", "meraki", "nautobot-device-lifecycle-mgmt", "netutils", "oauthlib", "python-magic", "pytz", "requests", "requests-oauthlib", "six", "slurpit-sdk", "urllib3"] +all = ["Jinja2", "PyYAML", "cloudvision", "cvprac", "dnacentersdk", "dnspython", "ijson", "ipfabric", "meraki", "nautobot-device-lifecycle-mgmt", "netutils", "oauthlib", "orionsdk", "python-magic", "pytz", "requests", "requests-oauthlib", "six", "slurpit-sdk", "urllib3"] aristacv = ["cloudvision", "cvprac"] bootstrap = ["pytz"] citrix-adm = ["netutils", "requests", "urllib3"] @@ -5924,8 +6054,9 @@ nautobot-device-lifecycle-mgmt = ["nautobot-device-lifecycle-mgmt"] pysnow = ["ijson", "oauthlib", "python-magic", "pytz", "requests", "requests-oauthlib", "six"] servicenow = ["Jinja2", "PyYAML", "ijson", "oauthlib", "python-magic", "pytz", "requests", "requests-oauthlib", "six"] slurpit = ["slurpit-sdk"] +solarwinds = ["orionsdk"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.13" -content-hash = "686a1e20a00ecbef1ac2e16c1ef9e295a3cc164ef0eabb5ee1507ad01c10b27c" +content-hash = "d7628d290402205c1846cedcf57baa7ae765d3a8acb672161e33bb4ac298c2b3" diff --git a/pyproject.toml b/pyproject.toml index 63ff2d397..03911ef98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nautobot-ssot" -version = "3.3.0" +version = "3.4.0" description = "Nautobot Single Source of Truth" authors = ["Network to Code, LLC "] license = "Apache-2.0" @@ -58,6 +58,7 @@ retry = "^0.9.2" dnacentersdk = { version = "^2.5.6", optional = true } meraki = { version = "^1.37.2,<1.46.0", optional = true } slurpit-sdk = { version = "^0.9.58", optional = true } +orionsdk = { version = "^0.4.0", optional = true } [tool.poetry.group.dev.dependencies] coverage = "*" @@ -115,6 +116,7 @@ all = [ "nautobot-device-lifecycle-mgmt", "netutils", "oauthlib", + "orionsdk", "python-magic", "pytz", "requests", @@ -158,6 +160,9 @@ meraki = [ slurpit = [ "slurpit_sdk", ] +solarwinds = [ + "orionsdk", +] # pysnow = "^0.7.17" # PySNow is currently pinned to an older version of pytz as a dependency, which blocks compatibility with newer # versions of Nautobot. See https://github.com/rbw/pysnow/pull/186 diff --git a/tasks.py b/tasks.py index 0ee4210e9..402c3ab8b 100644 --- a/tasks.py +++ b/tasks.py @@ -181,13 +181,13 @@ def docker_compose(context, command, **kwargs): return context.run(compose_command, env=build_env, **kwargs) -def run_command(context, command, **kwargs): +def run_command(context, command, service="nautobot", **kwargs): """Wrapper to run a command locally or inside the nautobot container.""" env = _read_command_env(kwargs.pop("env", None)) if is_truthy(context.nautobot_ssot.local): return context.run(command, **kwargs, env=env) else: - # Check if nautobot is running, no need to start another nautobot container to run a command + # Check if service is running, no need to start another container to run a command docker_compose_status = "ps --services --filter status=running" results = docker_compose(context, docker_compose_status, hide="out") @@ -195,10 +195,10 @@ def run_command(context, command, **kwargs): for env_name in env: command_env_args += f" --env={env_name}" - if "nautobot" in results.stdout: - compose_command = f"exec{command_env_args} nautobot {command}" + if service in results.stdout: + compose_command = f"exec{command_env_args} {service} {command}" else: - compose_command = f"run{command_env_args} --rm --entrypoint='{command}' nautobot" + compose_command = f"run{command_env_args} --rm --entrypoint='{command}' {service}" pty = kwargs.pop("pty", True) @@ -443,10 +443,14 @@ def shell_plus(context): run_command(context, command) -@task -def cli(context): - """Launch a bash shell inside the Nautobot container.""" - run_command(context, "bash") +@task( + help={ + "service": "Docker compose service name to launch cli in (default: nautobot).", + } +) +def cli(context, service="nautobot"): + """Launch a bash shell inside the container.""" + run_command(context, "bash", service=service) @task( @@ -768,7 +772,8 @@ def pylint(context): else: print("No migrations directory found, skipping migrations checks.") - raise Exit(code=exit_code) + if exit_code != 0: + raise Exit(code=exit_code) @task(aliases=("a",)) @@ -812,7 +817,8 @@ def ruff(context, action=None, target=None, fix=False, output_format="concise"): if not run_command(context, command, warn=True): exit_code = 1 - raise Exit(code=exit_code) + if exit_code != 0: + raise Exit(code=exit_code) @task