├── tests ├── __init__.py └── test_netbox_routeros.py ├── netbox_routeros ├── migrations │ ├── __init__.py │ ├── 0004_configureddevice_extra_configuration.py │ ├── 0002_configurationtemplate_tags.py │ ├── 0005_auto_20210318_1441.py │ ├── 0001_initial.py │ └── 0003_auto_20210317_1230.py ├── tests │ ├── __init__.py │ └── test_ros_config_maker.py ├── utilities │ ├── __init__.py │ ├── tables.py │ └── napalm.py ├── templates │ └── routeros │ │ ├── configuration_template_list.html │ │ ├── configured_device_list.html │ │ ├── configured_device_edit.html │ │ ├── bases │ │ └── object_edit.html │ │ ├── configuration_template_edit.html │ │ ├── configuration_template.html │ │ └── configured_device.html ├── __init__.py ├── static │ └── netbox_routeros │ │ └── ros-syntax.css ├── filters.py ├── navigation.py ├── forms.py ├── template_code.py ├── tables.py ├── urls.py ├── testing.py ├── models.py ├── views.py └── ros_config_maker.py ├── .pre-commit-config.yaml ├── README.rst ├── pyproject.toml ├── README.md ├── setup.py └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_routeros/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_routeros/tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /netbox_routeros/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_netbox_routeros.py: -------------------------------------------------------------------------------- 1 | from netbox_routeros import __version__ 2 | 3 | 4 | def test_version(): 5 | assert __version__ == "0.1.0" 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: stable 4 | hooks: 5 | - id: black 6 | language_version: python3.8 7 | exclude: ^src/python/gn/gnms2/netbox 8 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/configuration_template_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_list.html' %} 2 | {% load buttons %} 3 | 4 | 5 | {% block buttons %} 6 | {% add_button "plugins:netbox_routeros:configurationtemplate_add" %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | Release process 3 | --------------- 4 | 5 | .. code-block:: bash 6 | 7 | export VERSION=a.b.c 8 | 9 | poetry version $VERSION 10 | dephell convert 11 | black setup.py 12 | 13 | git add . 14 | git commit -m "Releasing version $VERSION" 15 | 16 | git tag "v$VERSION" 17 | git branch "v$VERSION" 18 | git push origin \ 19 | refs/tags/"v$VERSION" \ 20 | refs/heads/"v$VERSION" \ 21 | main 22 | 23 | # Wait for CI to pass 24 | 25 | poetry publish --build 26 | -------------------------------------------------------------------------------- /netbox_routeros/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | 3 | from extras.plugins import PluginConfig 4 | 5 | 6 | class RouterOsConfig(PluginConfig): 7 | name = "netbox_routeros" 8 | verbose_name = "Netbox RouterOS" 9 | description = "Manage RouterOS configuration from within Netbox" 10 | version = "0.1.0" 11 | author = "Adam Charnock" 12 | author_email = "adam.charnock@gardunha.net" 13 | base_url = "routeros" 14 | required_settings = [] 15 | default_settings = {} 16 | 17 | 18 | config = RouterOsConfig 19 | -------------------------------------------------------------------------------- /netbox_routeros/migrations/0004_configureddevice_extra_configuration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-17 13:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("netbox_routeros", "0003_auto_20210317_1230"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="configureddevice", 15 | name="extra_configuration", 16 | field=models.TextField(blank=True, default=""), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_routeros/utilities/tables.py: -------------------------------------------------------------------------------- 1 | from netbox_routeros import tables 2 | 3 | 4 | class TagColumn(tables.TemplateColumn): 5 | """ 6 | Display a list of tags assigned to the object. 7 | """ 8 | 9 | template_code = """ 10 | {% for tag in value.all %} 11 | {% include 'utilities/templatetags/tag.html' %} 12 | {% empty %} 13 | 14 | {% endfor %} 15 | """ 16 | 17 | def __init__(self, url_name=None, **kwargs): 18 | super().__init__( 19 | template_code=self.template_code, 20 | extra_context={"url_name": url_name}, 21 | **kwargs 22 | ) 23 | -------------------------------------------------------------------------------- /netbox_routeros/migrations/0002_configurationtemplate_tags.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-07 11:23 2 | 3 | from django.db import migrations 4 | import taggit.managers 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("extras", "0053_rename_webhook_obj_type"), 11 | ("netbox_routeros", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="configurationtemplate", 17 | name="tags", 18 | field=taggit.managers.TaggableManager( 19 | through="extras.TaggedItem", to="extras.Tag" 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /netbox_routeros/migrations/0005_auto_20210318_1441.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-18 14:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("netbox_routeros", "0004_configureddevice_extra_configuration"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="configureddevice", name="last_config_pushed", 15 | ), 16 | migrations.AddField( 17 | model_name="configureddevice", 18 | name="last_verbose_config_fetched", 19 | field=models.TextField(blank=True, default=""), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /netbox_routeros/static/netbox_routeros/ros-syntax.css: -------------------------------------------------------------------------------- 1 | /* 2 | https://coolors.co/e63946-f1faee-a8dadc-457b9d-1d3557 3 | #e63946 4 | #f1faee 5 | #a8dadc 6 | #457b9d 7 | #1d3557 8 | */ 9 | 10 | .ros { 11 | display: block; 12 | font-family: monospace; 13 | background-color: #f1faee; 14 | padding: 2rem; 15 | } 16 | /*Section path*/ 17 | .ros-p { 18 | font-weight: bold; 19 | color: #1d3557; 20 | } 21 | .ros-s > .ros-e > .ros-c { 22 | font-weight: bold; 23 | } 24 | 25 | .ros-c { 26 | color: #e63946; 27 | } 28 | .ros-k { 29 | color: #457b9d; 30 | } 31 | .ros-nat { 32 | border-bottom: 1px dashed rgba(69, 123, 157, 0.5); 33 | } 34 | .ros-fc { 35 | opacity: 0.7; 36 | } 37 | .ros-v { 38 | color: #1d3557; 39 | } 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "netbox-routeros" 3 | description = "Configure Mikrotik RouterOS devices using Netbox" 4 | version = "0.1.0" 5 | authors = ["Adam Charnock "] 6 | repository = "https://github.com/gardunha/netbox-routeros" 7 | license = "MIT" 8 | readme = "README.md" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | Jinja2 = "^2.11.3" 13 | 14 | [tool.poetry.dev-dependencies] 15 | pytest = "^5.2" 16 | dephell = "^0.8.3" 17 | black = "^20.8b1" 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | 23 | [tool.dephell.main] 24 | from = {format = "poetry", path = "pyproject.toml"} 25 | envs = ["main"] 26 | to = {format = "setuppy", path = "setup.py"} 27 | -------------------------------------------------------------------------------- /netbox_routeros/filters.py: -------------------------------------------------------------------------------- 1 | from netbox_routeros.models import ConfigurationTemplate, ConfiguredDevice 2 | from tenancy.filters import TenancyFilterSet 3 | from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet 4 | 5 | 6 | class ConfiguredDeviceFilterSet( 7 | BaseFilterSet, TenancyFilterSet, NameSlugSearchFilterSet 8 | ): 9 | class Meta: 10 | # TODO: Include device fields 11 | model = ConfiguredDevice 12 | fields = [ 13 | "device", 14 | "configuration_template", 15 | "last_config_fetched_at", 16 | "last_config_pushed_at", 17 | ] 18 | 19 | 20 | class ConfigurationTemplateFilterSet( 21 | BaseFilterSet, TenancyFilterSet, NameSlugSearchFilterSet 22 | ): 23 | class Meta: 24 | model = ConfigurationTemplate 25 | fields = ["id", "name", "slug"] 26 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/configured_device_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_list.html' %} 2 | {% load buttons %} 3 | 4 | 5 | {% block buttons %} 6 | {% add_button "plugins:netbox_routeros:configureddevice_add" %} 7 | {% endblock %} 8 | {% block bulk_buttons %} 9 | {{ block.super }} 10 | 13 | 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /netbox_routeros/navigation.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginMenuButton, PluginMenuItem 2 | from utilities.choices import ButtonColorChoices 3 | 4 | menu_items = ( 5 | PluginMenuItem( 6 | link="plugins:netbox_routeros:configureddevice_list", 7 | link_text="Configured devices", 8 | buttons=( 9 | PluginMenuButton( 10 | "plugins:netbox_routeros:configureddevice_add", 11 | "Add", 12 | "mdi mdi-plus-thick", 13 | ButtonColorChoices.GREEN, 14 | ), 15 | ), 16 | ), 17 | PluginMenuItem( 18 | link="plugins:netbox_routeros:configurationtemplate_list", 19 | link_text="Configuration templates", 20 | buttons=( 21 | PluginMenuButton( 22 | "plugins:netbox_routeros:configurationtemplate_add", 23 | "Add", 24 | "mdi mdi-plus-thick", 25 | ButtonColorChoices.GREEN, 26 | ), 27 | ), 28 | ), 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netbox plugin for auto-configuring Mikrotik RouterOS devices 2 | 3 | Features: 4 | 5 | * Templating config using Jinja2 6 | * Built-in functions to assist in templating 7 | * Full access to Netbox's underlying Django models 8 | * Will SSH into RouterOS devices to apply configuration updates 9 | * Smart diffing – Applies only the necessary commands to modify your device's configuraiton. No restart required 10 | * View current device configuration, the generated templated configuration, and the diff to be applied 11 | * Manage and edit configuration templates within Netbox 12 | * Bulk apply changes to multiple devices 13 | 14 | ## Release process 15 | 16 | ```bash 17 | export VERSION=a.b.c 18 | 19 | poetry version $VERSION 20 | dephell convert 21 | black setup.py 22 | 23 | git add . 24 | git commit -m "Releasing version $VERSION" 25 | 26 | git tag "v$VERSION" 27 | git branch "v$VERSION" 28 | git push origin \ 29 | refs/tags/"v$VERSION" \ 30 | refs/heads/"v$VERSION" \ 31 | main 32 | 33 | # Wait for CI to pass 34 | 35 | poetry publish --build 36 | ``` 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # DO NOT EDIT THIS FILE! 4 | # This file has been autogenerated by dephell <3 5 | # https://github.com/dephell/dephell 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | import os.path 14 | 15 | readme = "" 16 | here = os.path.abspath(os.path.dirname(__file__)) 17 | readme_path = os.path.join(here, "README.rst") 18 | if os.path.exists(readme_path): 19 | with open(readme_path, "rb") as stream: 20 | readme = stream.read().decode("utf8") 21 | 22 | 23 | setup( 24 | long_description=readme, 25 | name="netbox-routeros", 26 | version="0.1.0", 27 | description="Configure Mikrotik RouterOS devices using Netbox", 28 | python_requires="==3.*,>=3.8.0", 29 | project_urls={"repository": "https://github.com/gardunha/netbox-routeros"}, 30 | author="Adam Charnock", 31 | author_email="adam.charnock@gardunha.net", 32 | license="MIT", 33 | packages=[ 34 | "netbox_routeros", 35 | "netbox_routeros.migrations", 36 | "netbox_routeros.tests", 37 | "netbox_routeros.utilities", 38 | ], 39 | package_dir={"": "."}, 40 | package_data={ 41 | "netbox_routeros": [ 42 | "static/netbox_routeros/*.css", 43 | "templates/routeros/*.html", 44 | "templates/routeros/bases/*.html", 45 | ] 46 | }, 47 | install_requires=["jinja2==2.*,>=2.11.3"], 48 | ) 49 | -------------------------------------------------------------------------------- /netbox_routeros/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-07 10:56 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("tenancy", "0011_standardize_name_length"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="ConfigurationTemplate", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, primary_key=True, serialize=False 23 | ), 24 | ), 25 | ("created", models.DateField(auto_now_add=True, null=True)), 26 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 27 | ("name", models.CharField(max_length=100, unique=True)), 28 | ("slug", models.SlugField(max_length=100, unique=True)), 29 | ("content", models.TextField()), 30 | ( 31 | "tenant", 32 | models.ForeignKey( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.PROTECT, 36 | related_name="routeros_configuration_templates", 37 | to="tenancy.tenant", 38 | ), 39 | ), 40 | ], 41 | options={"abstract": False,}, 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /netbox_routeros/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from dcim.models import Device 4 | from extras.models import Tag 5 | from netbox_routeros.models import ConfigurationTemplate, ConfiguredDevice 6 | from tenancy.forms import TenancyForm 7 | from utilities.forms import ( 8 | BootstrapMixin, 9 | DynamicModelMultipleChoiceField, 10 | SlugField, 11 | DynamicModelChoiceField, 12 | ) 13 | 14 | TEMPLATE_PLACEHOLDER = ( 15 | "/ip address\n" "add address={{ device.loopback_address }} interface=loopback" 16 | ) 17 | 18 | 19 | class ConfiguredDeviceForm(BootstrapMixin, TenancyForm, forms.ModelForm): 20 | # TODO: API 21 | # device = DynamicModelChoiceField( 22 | # queryset=Device.objects.all(), 23 | # ) 24 | # configuration_template = DynamicModelChoiceField( 25 | # queryset=ConfigurationTemplate.objects.all(), 26 | # ) 27 | 28 | class Meta: 29 | model = ConfiguredDevice 30 | fields = [ 31 | "device", 32 | "configuration_template", 33 | "extra_configuration", 34 | ] 35 | 36 | 37 | class ConfigurationTemplateForm(BootstrapMixin, TenancyForm, forms.ModelForm): 38 | slug = SlugField() 39 | tags = DynamicModelMultipleChoiceField(queryset=Tag.objects.all(), required=False) 40 | content = forms.CharField( 41 | widget=forms.Textarea(attrs=dict(placeholder=TEMPLATE_PLACEHOLDER, rows=20)), 42 | label="", 43 | ) 44 | preview_for_device = DynamicModelChoiceField( 45 | queryset=Device.objects.all(), display_field="display_name", required=False, 46 | ) 47 | 48 | class Meta: 49 | model = ConfigurationTemplate 50 | fields = ["name", "slug", "tenant_group", "tenant", "content", "tags"] 51 | -------------------------------------------------------------------------------- /netbox_routeros/utilities/napalm.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django.conf import settings 4 | 5 | from netbox.api.exceptions import ServiceUnavailable 6 | 7 | if TYPE_CHECKING: 8 | from dcim.models import Device 9 | 10 | 11 | def get_napalm_driver(device: "Device"): 12 | # Credit: Code pulled from netbox core 13 | # Check that NAPALM is installed 14 | try: 15 | import napalm 16 | from napalm.base.exceptions import ModuleImportError 17 | except ModuleNotFoundError as e: 18 | if getattr(e, "name") == "napalm": 19 | raise ServiceUnavailable( 20 | "NAPALM is not installed. Please see the documentation for instructions." 21 | ) 22 | raise e 23 | 24 | # Validate the configured driver 25 | try: 26 | driver = napalm.get_network_driver(device.platform.napalm_driver) 27 | except ModuleImportError: 28 | raise ServiceUnavailable( 29 | "NAPALM driver for platform {} not found: {}.".format( 30 | device.platform, device.platform.napalm_driver 31 | ) 32 | ) 33 | 34 | host = str(device.primary_ip.address.ip) 35 | username = settings.NAPALM_USERNAME 36 | password = settings.NAPALM_PASSWORD 37 | optional_args = settings.NAPALM_ARGS.copy() 38 | if device.platform.napalm_args is not None: 39 | optional_args.update(device.platform.napalm_args) 40 | 41 | # Connect to the device 42 | d = driver( 43 | hostname=host, 44 | username=username, 45 | password=password, 46 | timeout=settings.NAPALM_TIMEOUT, 47 | optional_args=optional_args, 48 | ) 49 | 50 | # Note that we don't open the connection as 51 | # we do not require API access 52 | 53 | return d 54 | -------------------------------------------------------------------------------- /netbox_routeros/template_code.py: -------------------------------------------------------------------------------- 1 | CONFIGURED_DEVICE_LINK = """ 2 | 3 | {{ record.device.name }} 4 | {% if record.problems %}problems{% endif %} 5 | 6 | """ 7 | 8 | TEMPLATE_LINK = """ 9 | 10 | {{ record.name }} 11 | 12 | """ 13 | 14 | 15 | TEMPLATE_BUTTONS = """ 16 | {% if perms.netbox_routeros.change_configurationtemplate %} 17 | 18 | 19 | 20 | {% endif %} 21 | {% if perms.netbox_routeros.delete_configurationtemplate %} 22 | 23 | 24 | 25 | {% endif %} 26 | """ 27 | 28 | 29 | CONFIGURED_DEVICE_BUTTONS = """ 30 | 31 | 32 | 33 | {% if perms.netbox_routeros.change_configureddevice %} 34 | 35 | 36 | 37 | {% endif %} 38 | {% if perms.netbox_routeros.delete_configureddevice %} 39 | 40 | 41 | 42 | {% endif %} 43 | """ 44 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/configured_device_edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'routeros/bases/object_edit.html' %} 2 | {% load form_helpers %} 3 | 4 | {% block header %} 5 |
6 |
7 | 16 |
17 |
18 | {% endblock %} 19 | 20 | {% block form %} 21 |
22 |
23 |
24 |
Configured device
25 |
26 | {% render_field form.device %} 27 | {% render_field form.configuration_template %} 28 |
29 |
30 | 31 |
32 |
Tenancy
33 |
34 | {% render_field form.tenant_group %} 35 | {% render_field form.tenant %} 36 |
37 |
38 | 39 |
40 |
Extra configuration
41 |
42 | {{ form.extra_configuration }} 43 | {{ form.extra_configuration.help_text|safe }} 44 |
45 |
46 | 47 |
48 |
49 | {% endblock %} 50 | 51 | 52 | {% block buttons %} 53 |
54 |
55 | {{ block.super }} 56 |
57 |
58 | {% endblock %} 59 | -------------------------------------------------------------------------------- /netbox_routeros/tables.py: -------------------------------------------------------------------------------- 1 | from django_tables2 import tables, TemplateColumn, Column 2 | 3 | from netbox_routeros.models import ConfigurationTemplate, ConfiguredDevice 4 | from netbox_routeros.template_code import ( 5 | TEMPLATE_LINK, 6 | TEMPLATE_BUTTONS, 7 | CONFIGURED_DEVICE_LINK, 8 | CONFIGURED_DEVICE_BUTTONS, 9 | ) 10 | from netbox_routeros.utilities.tables import TagColumn 11 | from tenancy.tables import COL_TENANT 12 | from utilities.tables import ( 13 | BaseTable, 14 | ToggleColumn, 15 | ) 16 | 17 | 18 | class ConfiguredDeviceTable(BaseTable): 19 | pk = ToggleColumn() 20 | name = TemplateColumn( 21 | accessor="device.name", 22 | order_by=("_name",), 23 | template_code=CONFIGURED_DEVICE_LINK, 24 | ) 25 | primary_ip4 = Column( 26 | linkify=True, accessor="device.primary_ip4", verbose_name="IPv4 Address" 27 | ) 28 | primary_ip6 = Column( 29 | linkify=True, accessor="device.primary_ip6", verbose_name="IPv6 Address" 30 | ) 31 | tags = TagColumn(accessor="device.tags", url_name="dcim:device_list") 32 | 33 | actions = TemplateColumn(template_code=CONFIGURED_DEVICE_BUTTONS,) 34 | 35 | class Meta(BaseTable.Meta): 36 | model = ConfiguredDevice 37 | fields = ( 38 | "pk", 39 | "name", 40 | "configuration_template", 41 | "primary_ip4", 42 | "primary_ip6", 43 | "tags", 44 | "last_config_fetched_at", 45 | "last_config_pushed_at", 46 | "actions", 47 | ) 48 | default_columns = ( 49 | "name", 50 | "configuration_template", 51 | "primary_ip4", 52 | "primary_ip6", 53 | "tags", 54 | "last_config_fetched_at", 55 | "last_config_pushed_at", 56 | "actions", 57 | ) 58 | 59 | 60 | class ConfigurationTemplateTable(BaseTable): 61 | pk = ToggleColumn() 62 | name = TemplateColumn(order_by=("_name",), template_code=TEMPLATE_LINK) 63 | tenant = TemplateColumn(template_code=COL_TENANT) 64 | 65 | actions = TemplateColumn(template_code=TEMPLATE_BUTTONS,) 66 | 67 | class Meta(BaseTable.Meta): 68 | model = ConfigurationTemplate 69 | fields = ("pk", "name", "slug", "tenant", "actions") 70 | default_columns = ("name", "slug", "actions") 71 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/bases/object_edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load form_helpers %} 3 | {% load helpers %} 4 | 5 | {% block content %} 6 |
7 | {% csrf_token %} 8 | {% for field in form.hidden_fields %} 9 | {{ field }} 10 | {% endfor %} 11 |

12 | {% if settings.DOCS_ROOT %} 13 |
14 | 17 |
18 | {% endif %} 19 | {% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %} 20 |

21 | {% block tabs %}{% endblock %} 22 | {% if form.non_field_errors %} 23 |
24 |
Errors
25 |
26 | {{ form.non_field_errors }} 27 |
28 |
29 | {% endif %} 30 | {% block form %} 31 |
32 |
{{ obj_type|capfirst }}
33 |
34 | {% block form_fields %} 35 | {% render_form form %} 36 | {% endblock %} 37 |
38 |
39 | {% endblock %} 40 | 41 |
42 | {% block buttons %} 43 | {% if obj.pk %} 44 | 45 | {% else %} 46 | 47 | 48 | {% endif %} 49 | Cancel 50 | {% endblock %} 51 |
52 |
53 | {% if obj and settings.DOCS_ROOT %} 54 | {% include 'inc/modal.html' with name='docs' content=obj|get_docs %} 55 | {% endif %} 56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /netbox_routeros/migrations/0003_auto_20210317_1230.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-17 12:30 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("dcim", "0122_standardize_name_length"), 11 | ("tenancy", "0011_standardize_name_length"), 12 | ("netbox_routeros", "0002_configurationtemplate_tags"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name="configurationtemplate", options={"ordering": ["name"]}, 18 | ), 19 | migrations.CreateModel( 20 | name="ConfiguredDevice", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, primary_key=True, serialize=False 26 | ), 27 | ), 28 | ("created", models.DateField(auto_now_add=True, null=True)), 29 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 30 | ("last_config_fetched", models.TextField(blank=True, default="")), 31 | ( 32 | "last_config_fetched_at", 33 | models.DateTimeField(blank=True, default=None, null=True), 34 | ), 35 | ("last_config_pushed", models.TextField(blank=True, default="")), 36 | ( 37 | "last_config_pushed_at", 38 | models.DateTimeField(blank=True, default=None, null=True), 39 | ), 40 | ( 41 | "configuration_template", 42 | models.ForeignKey( 43 | on_delete=django.db.models.deletion.PROTECT, 44 | related_name="configured_devices", 45 | to="netbox_routeros.configurationtemplate", 46 | ), 47 | ), 48 | ( 49 | "device", 50 | models.OneToOneField( 51 | on_delete=django.db.models.deletion.CASCADE, 52 | related_name="routeros_configured_devices", 53 | to="dcim.device", 54 | ), 55 | ), 56 | ( 57 | "tenant", 58 | models.ForeignKey( 59 | blank=True, 60 | null=True, 61 | on_delete=django.db.models.deletion.PROTECT, 62 | related_name="routeros_configured_devices", 63 | to="tenancy.tenant", 64 | ), 65 | ), 66 | ], 67 | options={"ordering": ["device__name"],}, 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /netbox_routeros/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views import View 3 | 4 | from extras.views import ObjectChangeLogView 5 | from . import views 6 | from .models import ConfigurationTemplate, ConfiguredDevice 7 | 8 | app_name = "netbox_routeros" 9 | urlpatterns = [ 10 | # Configured devices 11 | path( 12 | "configured-device/", 13 | views.ConfiguredDeviceListView.as_view(), 14 | name="configureddevice_list", 15 | ), 16 | path( 17 | "configured-device/add/", 18 | views.ConfiguredDeviceEditView.as_view(), 19 | name="configureddevice_add", 20 | ), 21 | path("configured-device/import/", View.as_view(), name="configureddevice_import"), 22 | path( 23 | "configured-device/delete/", View.as_view(), name="configureddevice_bulk_delete" 24 | ), 25 | path( 26 | "configured-device//", 27 | views.ConfiguredDeviceView.as_view(), 28 | name="configureddevice", 29 | ), 30 | path( 31 | "configured-device//edit/", 32 | views.ConfiguredDeviceEditView.as_view(), 33 | name="configureddevice_edit", 34 | ), 35 | path( 36 | "configured-device//delete/", 37 | View.as_view(), 38 | name="configureddevice_delete", 39 | ), 40 | path( 41 | "configured-device//changelog/", 42 | ObjectChangeLogView.as_view(), 43 | name="configureddevice_changelog", 44 | kwargs={"model": ConfiguredDevice}, 45 | ), 46 | path( 47 | "configured-device/pull/", 48 | views.PullConfigView.as_view(), 49 | name="configureddevice_pull_config", 50 | ), 51 | path( 52 | "configured-device/push/", 53 | views.PushConfigView.as_view(), 54 | name="configureddevice_push_config", 55 | ), 56 | # Configuration templates 57 | path( 58 | "configuration-templates/", 59 | views.ConfigurationTemplateListView.as_view(), 60 | name="configurationtemplate_list", 61 | ), 62 | path( 63 | "configuration-templates/add/", 64 | views.ConfigurationTemplateEditView.as_view(), 65 | name="configurationtemplate_add", 66 | ), 67 | path( 68 | "configuration-templates/import/", 69 | View.as_view(), 70 | name="configurationtemplate_import", 71 | ), 72 | path( 73 | "configuration-templates/delete/", 74 | View.as_view(), 75 | name="configurationtemplate_bulk_delete", 76 | ), 77 | path( 78 | "configuration-templates//", 79 | views.ConfigurationTemplateView.as_view(), 80 | name="configurationtemplate", 81 | ), 82 | path( 83 | "configuration-templates//edit/", 84 | views.ConfigurationTemplateEditView.as_view(), 85 | name="configurationtemplate_edit", 86 | ), 87 | path( 88 | "configuration-templates//delete/", 89 | View.as_view(), 90 | name="configurationtemplate_delete", 91 | ), 92 | path( 93 | "configuration-templates//changelog/", 94 | ObjectChangeLogView.as_view(), 95 | name="configurationtemplate_changelog", 96 | kwargs={"model": ConfigurationTemplate}, 97 | ), 98 | ] 99 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/configuration_template_edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'routeros/bases/object_edit.html' %} 2 | {% load form_helpers %} 3 | 4 | {% block header %} 5 |
6 |
7 | 16 |
17 |
18 | {% endblock %} 19 | 20 | {% block form %} 21 |
22 |
23 |
24 |
Configuration Template
25 |
26 | {% render_field form.name %} 27 | {% render_field form.slug %} 28 |
29 |
30 | 31 |
32 |
Tenancy
33 |
34 | {% render_field form.tenant_group %} 35 | {% render_field form.tenant %} 36 |
37 |
38 | 39 | 40 |
41 |
Tags
42 |
43 | {% render_field form.tags %} 44 |
45 |
46 | 47 |
48 | 49 |
50 | 51 |
52 |
53 |
54 |
Template content
55 |
56 | {% render_field form.content %} 57 |
58 |
59 | 60 |
61 |
Preview configuration
62 |
63 | {% render_field form.preview_for_device %} 64 | {% if config_preview %} 65 | 69 |
{{ config_preview }}
70 | {% endif %} 71 | 72 |
73 |
74 |
75 |
76 | {% endblock %} 77 | 78 | 79 | {% block buttons %} 80 |
81 |
82 | {{ block.super }} 83 |
84 |
85 | {% endblock %} 86 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/configuration_template.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load render_table from django_tables2 %} 3 | {% load buttons %} 4 | {% load static %} 5 | {% load helpers %} 6 | {% load plugins %} 7 | 8 | {% block header %} 9 |
10 |
11 | 15 |
16 |
17 | {% if perms.routeros.change_configuration_template %} 18 | 19 | Edit 20 | 21 | {% endif %} 22 |
23 |
24 | {% endblock %} 25 | 26 | {% block content %} 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Configuration template 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 58 | 59 |
Name{{ object.name }}
Slug{{ object.slug }}
Tenant 49 | {% if object.tenant %} 50 | {% if object.tenant.group %} 51 | {{ object.tenant.group }} / 52 | {% endif %} 53 | {{ object.tenant }} 54 | {% else %} 55 | None 56 | {% endif %} 57 |
60 |
61 | 62 | {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:device_list' %} 63 | {% plugin_left_page object %} 64 |
65 |
66 |
67 |
68 | Content 69 |
70 |
71 |
{{ object.content }}
72 |
73 |
74 |
75 |
76 |
77 |
78 | {% plugin_full_width_page object %} 79 |
80 |
81 |
82 |
83 |
84 |
85 | {% include 'secrets/inc/private_key_modal.html' %} 86 | {% endblock %} 87 | 88 | {% block javascript %} 89 | 90 | {% endblock %} 91 | -------------------------------------------------------------------------------- /netbox_routeros/testing.py: -------------------------------------------------------------------------------- 1 | from ipaddress import IPv4Address, IPv6Address 2 | from typing import Sequence, Union 3 | 4 | from dcim.models import Site, DeviceRole, DeviceType, Device, Manufacturer, Interface 5 | from ipam.models import IPAddress, Prefix, VLAN 6 | 7 | FlexibleIpType = Union[str, IPv4Address, IPv6Address, IPAddress] 8 | 9 | 10 | class TestCaseMixin: 11 | data: "DataProvider" 12 | 13 | def setUp(self): 14 | self.data = DataProvider() 15 | 16 | 17 | class DataProvider: 18 | def device( 19 | self, 20 | device_type: DeviceType = None, 21 | device_role: DeviceRole = None, 22 | site: Site = None, 23 | **kwargs, 24 | ): 25 | values = dict( 26 | device_type=device_type or self.any_device_type(), 27 | device_role=device_role or self.any_device_role(), 28 | site=site or self.any_site(), 29 | ) 30 | values.update(**kwargs) 31 | return Device.objects.create(**values) 32 | 33 | def any_device( 34 | self, 35 | device_type: DeviceType = None, 36 | device_role: DeviceRole = None, 37 | site: Site = None, 38 | **kwargs, 39 | ): 40 | try: 41 | return Device.objects.latest("pk") 42 | except Device.DoesNotExist: 43 | return self.device( 44 | device_type=device_type, device_role=device_role, site=site, **kwargs 45 | ) 46 | 47 | def device_role(self, name: str = None, slug: str = None, **kwargs): 48 | number = DeviceRole.objects.count() + 1 49 | values = dict( 50 | name=name or f"Test device role {number}", 51 | slug=slug or f"test-device-role-{number}", 52 | **kwargs, 53 | ) 54 | values.update(**kwargs) 55 | return DeviceRole.objects.create(**values) 56 | 57 | def any_device_role(self, name: str = None, slug: str = None, **kwargs): 58 | try: 59 | return DeviceRole.objects.latest("pk") 60 | except DeviceRole.DoesNotExist: 61 | return self.device_role(name=name, slug=slug, **kwargs) 62 | 63 | def device_type( 64 | self, 65 | manufacturer: Manufacturer = None, 66 | model: str = None, 67 | slug: str = None, 68 | **kwargs, 69 | ): 70 | number = DeviceType.objects.count() + 1 71 | values = dict( 72 | manufacturer=manufacturer or self.any_manufacturer(), 73 | model="LASER-PANTHER-2000", 74 | slug=slug or f"test-device-type-{number}", 75 | **kwargs, 76 | ) 77 | values.update(**kwargs) 78 | return DeviceType.objects.create(**values) 79 | 80 | def any_device_type( 81 | self, 82 | manufacturer: Manufacturer = None, 83 | model: str = None, 84 | slug: str = None, 85 | **kwargs, 86 | ): 87 | try: 88 | return DeviceType.objects.latest("pk") 89 | except DeviceType.DoesNotExist: 90 | return self.device_type( 91 | manufacturer=manufacturer, model=model, slug=slug, **kwargs 92 | ) 93 | 94 | def manufacturer(self, name: str = None, slug: str = None, **kwargs): 95 | number = DeviceType.objects.count() + 1 96 | values = dict( 97 | name=name or f"Test manufacturer {number}", 98 | slug=slug or f"test-manufacturer-{number}", 99 | **kwargs, 100 | ) 101 | values.update(**kwargs) 102 | return Manufacturer.objects.create(**values) 103 | 104 | def any_manufacturer(self, name: str = None, slug: str = None, **kwargs): 105 | try: 106 | return Manufacturer.objects.latest("pk") 107 | except Manufacturer.DoesNotExist: 108 | return self.manufacturer(name=name, slug=slug, **kwargs) 109 | 110 | def site(self, name: str = None, slug: str = None, **kwargs): 111 | number = DeviceType.objects.count() + 1 112 | values = dict( 113 | name=name or f"Test site {number}", 114 | slug=slug or f"test-site-{number}", 115 | **kwargs, 116 | ) 117 | values.update(**kwargs) 118 | return Site.objects.create(**values) 119 | 120 | def any_site(self, name: str = None, slug: str = None, **kwargs): 121 | try: 122 | return Site.objects.latest("pk") 123 | except Site.DoesNotExist: 124 | return self.site(name=name, slug=slug, **kwargs) 125 | 126 | def interface( 127 | self, 128 | device: Device, 129 | name: str = None, 130 | ip_address: Union[FlexibleIpType, Sequence[FlexibleIpType]] = None, 131 | vlan: VLAN = None, 132 | **kwargs, 133 | ): 134 | """Create an interface, as well as an IP addresses specified""" 135 | number = Interface.objects.count() + 1 136 | values = dict(device=device, name=name or f"ether{number}", **kwargs) 137 | interface = Interface.objects.create(**values) 138 | 139 | ip_address = ip_address or [] 140 | ip_addresses = ( 141 | ip_address if isinstance(ip_address, (tuple, list)) else [ip_address] 142 | ) 143 | for ip in ip_addresses: 144 | if isinstance(ip, IPAddress): 145 | ip.assigned_object = interface 146 | ip.save() 147 | else: 148 | IPAddress.objects.create( 149 | address=ip, assigned_object=interface, 150 | ) 151 | 152 | if vlan: 153 | interface.tagged_vlans.add(vlan) 154 | 155 | return interface 156 | 157 | def prefix(self, prefix: str = None, **kwargs): 158 | number = Prefix.objects.count() 159 | values = dict(prefix=prefix or f"10.123.{number}.0/24",) 160 | return Prefix.objects.create(**values) 161 | 162 | def vlan(self, vid: int = None, name: str = None): 163 | number = VLAN.objects.count() + 1 164 | values = dict( 165 | vid=number + 1000 if vid is None else vid, 166 | name=name or f"Test VLAN {number}", 167 | ) 168 | return VLAN.objects.create(**values) 169 | 170 | def ip_address(self, address=None, **kwargs): 171 | number = IPAddress.objects.count() 172 | values = dict(address=address or f"10.99.99.{number}", **kwargs) 173 | return IPAddress.objects.create(**values) 174 | -------------------------------------------------------------------------------- /netbox_routeros/tests/test_ros_config_maker.py: -------------------------------------------------------------------------------- 1 | from netaddr import IPAddress, IPNetwork 2 | 3 | from ipam.models import Prefix 4 | from netbox_routeros.ros_config_maker import ( 5 | _context_prefixes, 6 | get_interface, 7 | combine_prefixes, 8 | get_loopback, 9 | ) 10 | from netbox_routeros.testing import TestCaseMixin 11 | from utilities.testing import TestCase 12 | 13 | 14 | class TestContextData(TestCaseMixin, TestCase): 15 | def test_context_ip_addresses(self): 16 | pass 17 | 18 | 19 | class GetLoopbackCase(TestCaseMixin, TestCase): 20 | def test_has_loopback(self): 21 | device = self.data.device() 22 | ip_address_obj = self.data.ip_address("100.127.0.1", role="loopback") 23 | self.ether1 = self.data.interface(device=device, ip_address=ip_address_obj) 24 | self.assertEqual(get_loopback(device), IPAddress("100.127.0.1")) 25 | 26 | def test_has_two_loopback(self): 27 | device = self.data.device() 28 | ip_address_obj1 = self.data.ip_address("100.127.0.11", role="loopback") 29 | ip_address_obj2 = self.data.ip_address("100.127.0.10", role="loopback") 30 | self.ether1 = self.data.interface(device=device, ip_address=ip_address_obj1) 31 | self.ether1 = self.data.interface(device=device, ip_address=ip_address_obj2) 32 | 33 | self.assertEqual(get_loopback(device), IPAddress("100.127.0.10")) 34 | self.assertEqual(get_loopback(device, number=1), IPAddress("100.127.0.10")) 35 | self.assertEqual(get_loopback(device, number=2), IPAddress("100.127.0.11")) 36 | 37 | def test_has_no_loopback(self): 38 | device = self.data.device() 39 | ip_address_obj = self.data.ip_address("10.0.0.1") 40 | self.assertEqual(get_loopback(device), None) 41 | 42 | 43 | class CombinePrefixesTestCase(TestCaseMixin, TestCase): 44 | def setUp(self): 45 | super().setUp() 46 | self.prefix1 = self.data.prefix("10.0.0.0/24") 47 | self.prefix2 = self.data.prefix("10.0.1.0/24") 48 | self.prefix3 = self.data.prefix("10.0.5.0/24") 49 | 50 | def test_list(self): 51 | self.assertEqual( 52 | combine_prefixes([self.prefix1, self.prefix2, self.prefix3]), 53 | [IPNetwork("10.0.0.0/23"), IPNetwork("10.0.5.0/24")], 54 | ) 55 | 56 | def test_queryset(self): 57 | self.assertEqual( 58 | combine_prefixes(Prefix.objects.all()), 59 | [IPNetwork("10.0.0.0/23"), IPNetwork("10.0.5.0/24")], 60 | ) 61 | 62 | def test_only_combined(self): 63 | self.assertEqual( 64 | combine_prefixes( 65 | [self.prefix1, self.prefix2, self.prefix3], only_combined=True 66 | ), 67 | [IPNetwork("10.0.0.0/23")], 68 | ) 69 | 70 | 71 | class GetInterfaceTestCase(TestCaseMixin, TestCase): 72 | def setUp(self): 73 | super().setUp() 74 | self.device = self.data.device() 75 | self.vlan1 = self.data.vlan() 76 | self.ether1 = self.data.interface( 77 | device=self.device, ip_address=["10.0.0.1/24"] 78 | ) 79 | self.ether2 = self.data.interface( 80 | device=self.device, ip_address=["10.5.0.1/24"], vlan=self.vlan1 81 | ) 82 | 83 | def test_from_ip_address(self): 84 | self.assertEqual(get_interface(self.device, "10.5.0.1"), self.ether2) 85 | 86 | def test_from_ip_address_with_prefix(self): 87 | self.assertEqual(get_interface(self.device, "10.5.0.1/24"), self.ether2) 88 | 89 | def test_from_prefix_obj(self): 90 | prefix = self.data.prefix("10.5.0.0/24") 91 | self.assertEqual(get_interface(self.device, prefix), self.ether2) 92 | 93 | def test_from_ip_address_obj(self): 94 | prefix = self.data.ip_address("10.5.0.0/24") 95 | self.assertEqual(get_interface(self.device, prefix), self.ether2) 96 | 97 | def test_from_ip_netaddr_network(self): 98 | self.assertEqual( 99 | get_interface(self.device, IPNetwork("10.5.0.0/24")), self.ether2 100 | ) 101 | 102 | def test_from_ip_netaddr_address(self): 103 | self.assertEqual(get_interface(self.device, IPAddress("10.5.0.1")), self.ether2) 104 | 105 | def test_from_prefix_network(self): 106 | self.assertEqual(get_interface(self.device, "10.5.0.0/24"), self.ether2) 107 | 108 | def test_from_vlan(self): 109 | self.assertEqual(get_interface(self.device, self.vlan1), self.ether2) 110 | 111 | 112 | class GetVlanInterfaceTestCase(TestCaseMixin, TestCase): 113 | def setUp(self): 114 | super().setUp() 115 | 116 | self.device = self.data.device() 117 | self.ether1 = self.data.interface(device=self.device, ip_address=[]) 118 | self.ether2 = self.data.interface( 119 | device=self.device, ip_address=["10.5.0.1/24"] 120 | ) 121 | 122 | self.ip1 = self.data.ip_address("10.0.0.1/24") 123 | self.ip2 = self.data.ip_address("10.5.0.1/24") 124 | 125 | self.prefix1 = self.data.prefix("10.0.0.0/24") 126 | self.prefix2 = self.data.prefix("10.5.0.0/24") 127 | 128 | self.vlan1 = self.data.vlan() 129 | self.vlan2 = self.data.vlan() 130 | 131 | def test_ok(self): 132 | # Attach the vlan to the interface 133 | self.ether1.tagged_vlans.add(self.vlan1) 134 | 135 | # Put the prefixes on the vlan 136 | self.prefix1.vlan = self.vlan1 137 | self.prefix2.vlan = self.vlan1 138 | self.prefix1.save() 139 | self.prefix2.save() 140 | 141 | vlan = get_interface(self.device, "10.0.0.123", include_vlans=True) 142 | self.assertEqual(vlan, self.vlan1) 143 | 144 | def test_vlan_not_on_interface(self): 145 | # Attach the vlan to the interface 146 | # [NOPE] 147 | 148 | # Put the prefixes on the vlan 149 | self.prefix1.vlan = self.vlan1 150 | self.prefix2.vlan = self.vlan1 151 | self.prefix1.save() 152 | self.prefix2.save() 153 | 154 | vlan = get_interface(self.device, "10.0.0.123", include_vlans=True) 155 | self.assertEqual(vlan, None) 156 | 157 | def test_prefix_not_on_vlan(self): 158 | # Attach the vlan to the interface 159 | self.ether1.tagged_vlans.add(self.vlan1) 160 | 161 | # Put the prefixes on the vlan 162 | # [NOPE] 163 | 164 | vlan = get_interface(self.device, "10.0.0.123", include_vlans=True) 165 | self.assertEqual(vlan, None) 166 | -------------------------------------------------------------------------------- /netbox_routeros/models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import napalm 4 | from django.db import models 5 | from django.utils.functional import cached_property 6 | from django.utils.timezone import now 7 | from napalm.base import ModuleImportError 8 | from routeros_diff import RouterOSConfig 9 | from taggit.managers import TaggableManager 10 | 11 | from extras.models import ChangeLoggedModel, TaggedItem 12 | from netbox.api.exceptions import ServiceUnavailable 13 | from netbox_routeros.ros_config_maker import render_ros_config 14 | from netbox_routeros.utilities.napalm import get_napalm_driver 15 | from utilities.querysets import RestrictedQuerySet 16 | 17 | 18 | class ConfiguredDevice(ChangeLoggedModel): 19 | device = models.OneToOneField( 20 | to="dcim.Device", 21 | on_delete=models.CASCADE, 22 | related_name="routeros_configured_devices", 23 | ) 24 | configuration_template = models.ForeignKey( 25 | to="netbox_routeros.ConfigurationTemplate", 26 | on_delete=models.PROTECT, 27 | related_name="configured_devices", 28 | ) 29 | extra_configuration = models.TextField( 30 | default="", 31 | blank=True, 32 | help_text=( 33 | "Extra configuration to include in addition to the config template. " 34 | "You can use the Jinja2 template syntax, and you have access to all " 35 | "of the device's template context." 36 | ), 37 | ) 38 | 39 | tenant = models.ForeignKey( 40 | to="tenancy.Tenant", 41 | on_delete=models.PROTECT, 42 | related_name="routeros_configured_devices", 43 | blank=True, 44 | null=True, 45 | ) 46 | 47 | last_config_fetched = models.TextField( 48 | default="", 49 | blank=True, 50 | verbose_name="The last config which was fetched from the device", 51 | ) 52 | last_verbose_config_fetched = models.TextField( 53 | default="", 54 | blank=True, 55 | verbose_name="The last config which was fetched from the device (the verbose version)", 56 | ) 57 | last_config_fetched_at = models.DateTimeField(default=None, null=True, blank=True) 58 | 59 | last_config_pushed_at = models.DateTimeField(default=None, null=True, blank=True) 60 | 61 | objects = RestrictedQuerySet.as_manager() 62 | 63 | class Meta: 64 | ordering = ["device__name"] 65 | 66 | def __str__(self): 67 | if self.device_id: 68 | return str(self.device) 69 | else: 70 | return "New configured device" 71 | 72 | def generate_config(self) -> RouterOSConfig: 73 | config = render_ros_config( 74 | self.device, 75 | template_name=self.configuration_template.slug, 76 | template_content=self.configuration_template.content, 77 | extra_config=self.extra_configuration, 78 | ) 79 | return RouterOSConfig.parse(config) 80 | 81 | def parse_last_config_fetched(self) -> Optional[RouterOSConfig]: 82 | if self.last_config_fetched: 83 | return RouterOSConfig.parse(self.last_config_fetched) 84 | else: 85 | return None 86 | 87 | def parse_last_verbose_config_fetched(self) -> Optional[RouterOSConfig]: 88 | if self.last_verbose_config_fetched: 89 | return RouterOSConfig.parse(self.last_verbose_config_fetched) 90 | else: 91 | return None 92 | 93 | def generate_diff(self) -> Optional[RouterOSConfig]: 94 | old = self.parse_last_config_fetched() 95 | if not old: 96 | return 97 | return self.generate_config().diff( 98 | old=self.parse_last_config_fetched(), 99 | old_verbose=self.parse_last_verbose_config_fetched(), 100 | ) 101 | 102 | @cached_property 103 | def problems(self): 104 | """There are a bunch of things that may cause issues. Let's check for them 105 | proactively and let the user know""" 106 | problems = [] 107 | if not self.device.primary_ip: 108 | problems.append( 109 | "Device has no primary IP set. Set a primary IP to enable connecting to this device" 110 | ) 111 | 112 | # Check device has a platform 113 | if not self.device.platform: 114 | problems.append("No platform has been configured for this device") 115 | 116 | # Check that NAPALM is installed 117 | try: 118 | import napalm 119 | from napalm.base.exceptions import ModuleImportError 120 | except ModuleNotFoundError as e: 121 | if getattr(e, "name") == "napalm": 122 | problems.append( 123 | "NAPALM is not installed. Please install the napalm package." 124 | ) 125 | return problems 126 | 127 | # Validate the configured driver 128 | if not self.device.platform: 129 | problems.append("No platform configured for device") 130 | return 131 | 132 | if not self.device.platform.napalm_driver: 133 | problems.append("Device's platform has no napalm driver set") 134 | return 135 | 136 | try: 137 | napalm.get_network_driver(self.device.platform.napalm_driver) 138 | except ModuleImportError: 139 | problems.append( 140 | f"NAPALM driver for platform {self.device.platform} not found: {self.device.platform.napalm_driver}." 141 | ) 142 | 143 | return problems 144 | 145 | def fetch_config(self): 146 | driver = get_napalm_driver(self.device) 147 | self.last_config_fetched = driver.get_config( 148 | retrieve="running", full=False, sanitized=False 149 | )["running"] 150 | self.last_verbose_config_fetched = driver.get_config( 151 | retrieve="running", full=True, sanitized=False 152 | )["running"] 153 | self.last_config_fetched_at = now() 154 | self.save() 155 | 156 | def push_config(self): 157 | driver = get_napalm_driver(self.device) 158 | driver.load_replace_candidate( 159 | config=self.generate_config(), 160 | current_config=self.parse_last_config_fetched(), 161 | current_config_verbose=self.parse_last_verbose_config_fetched(), 162 | ) 163 | self.last_config_pushed_at = now() 164 | self.fetch_config() 165 | self.save() 166 | 167 | 168 | class ConfigurationTemplate(ChangeLoggedModel): 169 | name = models.CharField(max_length=100, unique=True) 170 | slug = models.SlugField(max_length=100, unique=True) 171 | tenant = models.ForeignKey( 172 | to="tenancy.Tenant", 173 | on_delete=models.PROTECT, 174 | related_name="routeros_configuration_templates", 175 | blank=True, 176 | null=True, 177 | ) 178 | tags = TaggableManager(through=TaggedItem) 179 | content = models.TextField() 180 | 181 | objects = RestrictedQuerySet.as_manager() 182 | 183 | class Meta: 184 | ordering = ["name"] 185 | 186 | def __str__(self): 187 | return self.name 188 | -------------------------------------------------------------------------------- /netbox_routeros/views.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from inspect import isclass 3 | from typing import Optional, Tuple 4 | 5 | from django.contrib import messages 6 | from django.db.models import Model 7 | from django.http import HttpResponseRedirect, HttpResponseForbidden 8 | from django.shortcuts import render 9 | from django.views import View 10 | from jinja2 import TemplateError, TemplateNotFound 11 | from napalm.base.exceptions import CommandErrorException 12 | from paramiko import AuthenticationException 13 | 14 | from dcim.models import Device 15 | from routeros_diff import RouterOSConfig 16 | from netbox.views import generic 17 | from netbox_routeros.models import ConfigurationTemplate, ConfiguredDevice 18 | from pprint import pformat 19 | 20 | from utilities.views import GetReturnURLMixin 21 | from . import filters 22 | from . import forms 23 | from . import tables 24 | from .ros_config_maker import render_ros_config, make_ros_config_context 25 | 26 | 27 | class ConfiguredDeviceListView(generic.ObjectListView): 28 | queryset = ConfiguredDevice.objects.select_related( 29 | "device", "configuration_template" 30 | ).all() 31 | filterset = filters.ConfiguredDeviceFilterSet 32 | filterset_form = forms.ConfiguredDeviceForm 33 | table = tables.ConfiguredDeviceTable 34 | template_name = "routeros/configured_device_list.html" 35 | action_buttons = [] 36 | 37 | 38 | class ConfiguredDeviceEditView(generic.ObjectEditView): 39 | # TODO: Don't allow editing of device field 40 | queryset = ConfiguredDevice.objects.all() 41 | model_form = forms.ConfiguredDeviceForm 42 | template_name = "routeros/configured_device_edit.html" 43 | default_return_url = "plugins:netbox_routeros:configureddevice_list" 44 | 45 | 46 | class PullConfigView(GetReturnURLMixin, View): 47 | def post(self, request): 48 | # Verify user permission 49 | if not request.user.has_perm("dcim.napalm_write_device"): 50 | return HttpResponseForbidden() 51 | 52 | pks = request.POST.getlist("pk") 53 | objs = ConfiguredDevice.objects.filter(pk__in=pks) 54 | for configured_device in objs: 55 | if not configured_device.problems: 56 | try: 57 | configured_device.fetch_config() 58 | except AuthenticationException as e: 59 | messages.error(request, str(e)) 60 | 61 | messages.success(request, f"Fetched {len(pks)} device configurations") 62 | return HttpResponseRedirect(self.get_return_url(request)) 63 | 64 | 65 | class PushConfigView(GetReturnURLMixin, View): 66 | def post(self, request): 67 | # Verify user permission 68 | if not request.user.has_perm("dcim.napalm_write_device"): 69 | return HttpResponseForbidden() 70 | 71 | pks = request.POST.getlist("pk") 72 | objs = ConfiguredDevice.objects.filter(pk__in=pks) 73 | 74 | successes = 0 75 | for configured_device in objs: 76 | if not configured_device.problems: 77 | try: 78 | configured_device.push_config() 79 | except CommandErrorException as e: 80 | messages.error(request, str(e)) 81 | else: 82 | successes += 1 83 | 84 | message = f"Pushed {successes} device configuration(s)" 85 | if successes: 86 | messages.success(request, message) 87 | else: 88 | messages.warning(request, message) 89 | 90 | return HttpResponseRedirect(self.get_return_url(request)) 91 | 92 | 93 | class ConfiguredDeviceView(generic.ObjectView): 94 | queryset = ConfiguredDevice.objects.all() 95 | template_name = "routeros/configured_device.html" 96 | 97 | def get_extra_context(self, request, instance: ConfiguredDevice): 98 | config_generated, error = render_configured_device_config_for_display( 99 | configured_device=instance, 100 | ) 101 | bootstrap_config, bootstrap_error = render_bootstrap_for_display( 102 | device=instance.device 103 | ) 104 | 105 | if not error: 106 | diff = instance.generate_diff() 107 | else: 108 | diff = None 109 | 110 | return { 111 | **get_template_context(instance.device), 112 | "config_generated": config_generated.__html__() 113 | if config_generated and config_generated.sections 114 | else f"
{error}
", 115 | "config_latest": instance.parse_last_config_fetched().__html__() 116 | if instance.last_config_fetched 117 | else None, 118 | "config_diff": diff.__html__() if diff and diff.sections else None, 119 | "config_bootstrap": bootstrap_config or f"
{bootstrap_error}
", 120 | } 121 | 122 | 123 | # Configuration templates 124 | 125 | 126 | class ConfigurationTemplateListView(generic.ObjectListView): 127 | queryset = ConfigurationTemplate.objects.all() 128 | filterset = filters.ConfigurationTemplateFilterSet 129 | filterset_form = forms.ConfigurationTemplateForm 130 | table = tables.ConfigurationTemplateTable 131 | template_name = "routeros/configuration_template_list.html" 132 | action_buttons = [] 133 | 134 | 135 | class ConfigurationTemplateEditView(generic.ObjectEditView): 136 | queryset = ConfigurationTemplate.objects.all() 137 | model_form = forms.ConfigurationTemplateForm 138 | template_name = "routeros/configuration_template_edit.html" 139 | default_return_url = "plugins:netbox_routeros:configurationtemplate_list" 140 | 141 | def post(self, request, *args, **kwargs): 142 | if not request.POST.get("_preview"): 143 | return super().post(request, *args, **kwargs) 144 | else: 145 | return self.render_preview(request, *args, **kwargs) 146 | 147 | def render_preview(self, request, *args, **kwargs): 148 | obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) 149 | form = self.model_form(data=request.POST, files=request.FILES, instance=obj) 150 | 151 | if not form.is_valid(): 152 | return super().post(request, *args, **kwargs) 153 | 154 | form.instance.content = form.cleaned_data["content"] 155 | temporary_configured_device = ConfiguredDevice( 156 | device=form.cleaned_data["preview_for_device"], 157 | configuration_template=form.instance, 158 | ) 159 | 160 | config_preview, error = render_configured_device_config_for_display( 161 | configured_device=temporary_configured_device, 162 | ) 163 | 164 | return render( 165 | request, 166 | self.template_name, 167 | { 168 | "obj": obj, 169 | "obj_type": self.queryset.model._meta.verbose_name, 170 | "form": form, 171 | "return_url": self.get_return_url(request, obj), 172 | "config_preview": str(config_preview) if config_preview else error, 173 | }, 174 | ) 175 | 176 | 177 | class ConfigurationTemplateView(generic.ObjectView): 178 | queryset = ConfigurationTemplate.objects.all() 179 | template_name = "routeros/configuration_template.html" 180 | 181 | 182 | def get_template_context(device: Device): 183 | context = make_ros_config_context(device=device) 184 | context_models = { 185 | k: v for k, v in context.items() if isclass(v) and issubclass(v, Model) 186 | } 187 | context_functions = { 188 | k: v for k, v in context.items() if callable(v) and k not in context_models 189 | } 190 | context_values = { 191 | k: v 192 | for k, v in context.items() 193 | if k not in context_models and k not in context_functions 194 | } 195 | 196 | return { 197 | "context_values": pformat(context_values, sort_dicts=True), 198 | "context_functions": pformat(context_functions, sort_dicts=True), 199 | "context_models": pformat(context_models, sort_dicts=True), 200 | } 201 | 202 | 203 | def render_configured_device_config_for_display( 204 | configured_device: ConfiguredDevice, 205 | ) -> Tuple[Optional[RouterOSConfig], Optional[str]]: 206 | """Render a config for display to a user 207 | 208 | Adds some niceties around error rendering 209 | """ 210 | error = None 211 | config = None 212 | try: 213 | config = configured_device.generate_config() 214 | except Exception: 215 | error = traceback.format_exc() 216 | 217 | return config, error 218 | 219 | 220 | def render_bootstrap_for_display( 221 | device: Device, 222 | ) -> Tuple[Optional[str], Optional[str]]: 223 | error = None 224 | config = None 225 | try: 226 | config = render_ros_config(device=device, template_name="bootstrap") 227 | except TemplateNotFound: 228 | # Just return None 229 | pass 230 | except Exception: 231 | error = traceback.format_exc() 232 | 233 | return config, error 234 | -------------------------------------------------------------------------------- /netbox_routeros/ros_config_maker.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from ipaddress import ( 3 | collapse_addresses, 4 | IPv4Interface, 5 | IPv6Address, 6 | IPv6Network, 7 | IPv4Network, 8 | ip_network, 9 | ip_interface, 10 | ) 11 | from typing import Union, List, Optional 12 | 13 | import django.apps 14 | from django.contrib.postgres.fields import ArrayField 15 | from django.db.models import Func 16 | from django.db.models.functions import Cast 17 | from django.utils.module_loading import import_string 18 | from jinja2 import Environment, BaseLoader, TemplateNotFound 19 | import netaddr 20 | 21 | from dcim.models import Device, Interface 22 | from ipam.fields import IPAddressField 23 | from ipam.models import IPAddress, VLAN, Q, Prefix 24 | from utilities.utils import deepmerge 25 | 26 | 27 | class Any(Func): 28 | function = "ANY" 29 | 30 | 31 | class RosTemplateLoader(BaseLoader): 32 | def __init__(self, overrides: dict = None): 33 | self.overrides = overrides or {} 34 | 35 | def get_source(self, environment, template): 36 | from netbox_routeros.models import ConfigurationTemplate 37 | 38 | # TODO: Does not support tenants 39 | if template in self.overrides: 40 | return ( 41 | self.overrides[template], 42 | template, 43 | lambda: content == self.overrides[template], 44 | ) 45 | 46 | try: 47 | content = ConfigurationTemplate.objects.get(slug=template).content 48 | except ConfigurationTemplate.DoesNotExist: 49 | raise TemplateNotFound(template) 50 | else: 51 | return ( 52 | content, 53 | template, 54 | lambda: content 55 | == ConfigurationTemplate.objects.get(slug=template).content, 56 | ) 57 | 58 | def list_templates(self): 59 | from netbox_routeros.models import ConfigurationTemplate 60 | 61 | return ConfigurationTemplate.objects.all().values_list("slug", flat=True) 62 | 63 | 64 | def render_ros_config( 65 | device: Device, 66 | template_name: str, 67 | template_content: str = None, 68 | extra_config: str = "", 69 | ): 70 | overrides = {} 71 | if template_name and template_content: 72 | overrides[template_name] = template_content 73 | if extra_config: 74 | overrides["_extra_config"] = extra_config 75 | 76 | env = Environment(loader=RosTemplateLoader(overrides),) 77 | template = env.get_template(template_name) 78 | context = make_ros_config_context(device) 79 | 80 | config = template.render(**context) 81 | 82 | if extra_config: 83 | template = env.get_template("_extra_config") 84 | rendered_extra_config = template.render(**context) 85 | config += f"\n{rendered_extra_config}" 86 | 87 | return config 88 | 89 | 90 | def make_ros_config_context(device: Device): 91 | # Make all models available for custom querying 92 | models = {m._meta.object_name: m for m in django.apps.apps.get_models()} 93 | 94 | context = dict( 95 | device=device, 96 | vlans=_context_vlans(device), 97 | **_context_ip_addresses(device), 98 | **_context_prefixes(device), 99 | **get_template_functions(device), 100 | **models, 101 | ) 102 | return dict(deepmerge(context, device.get_config_context())) 103 | 104 | 105 | def _context_ip_addresses(device: Device): 106 | # TODO: Test 107 | addresses = IPAddress.objects.filter(interface__device=device) 108 | return dict( 109 | ip_addresses=addresses, 110 | ip_addresses_v4=addresses.filter(address__family=4), 111 | ip_addresses_v6=addresses.filter(address__family=6), 112 | ) 113 | 114 | 115 | def _context_vlans(device: Device): 116 | return VLAN.objects.filter( 117 | prefixes__prefix__net_contains=_any_address(device) 118 | ).distinct() 119 | 120 | 121 | def _context_prefixes(device: Device): 122 | # TODO: Test 123 | prefixes = Prefix.objects.filter( 124 | prefix__net_contains=_any_address(device) 125 | ).distinct() 126 | return dict( 127 | prefixes=prefixes, 128 | prefixes_v4=prefixes.filter(prefix__family=4), 129 | prefixes_v6=prefixes.filter(prefix__family=6), 130 | ) 131 | 132 | 133 | def _any_address(device: Device): 134 | """Utility for querying against any device address""" 135 | addresses = [ 136 | str(ip.ip) 137 | for ip in IPAddress.objects.filter(interface__device=device).values_list( 138 | "address", flat=True 139 | ) 140 | ] 141 | addresses = Cast(addresses, output_field=ArrayField(IPAddressField())) 142 | return Any(addresses) 143 | 144 | 145 | def get_template_functions(device): 146 | return dict( 147 | get_loopback=get_loopback, 148 | get_prefix=get_prefix, 149 | combine_prefixes=combine_prefixes, 150 | get_interface=partial(get_interface, device), 151 | get_address=get_address, 152 | orm_or=orm_or, 153 | run_python_function=run_python_function, 154 | ) 155 | 156 | 157 | def get_loopback(device: Device, number=1, **extra_filters) -> Optional[IPAddress]: 158 | qs = IPAddress.objects.filter( 159 | interface__device=device, role="loopback", **extra_filters 160 | ).order_by("address") 161 | try: 162 | loopback = qs[number - 1 : number].get() 163 | except IPAddress.DoesNotExist: 164 | return None 165 | 166 | if loopback: 167 | return loopback.address.ip 168 | 169 | 170 | def combine_prefixes(prefixes, only_combined=False): 171 | in_prefixes = [ 172 | ip_network(p.prefix if isinstance(p, Prefix) else p) for p in prefixes 173 | ] 174 | out_prefixes = list( 175 | collapse_addresses([p for p in in_prefixes if p.version == 4]) 176 | ) + list(collapse_addresses([p for p in in_prefixes if p.version == 6])) 177 | 178 | if only_combined: 179 | out_prefixes = [p for p in out_prefixes if p not in in_prefixes] 180 | 181 | # Ensure we use the netaddr IPAddress, rather than the ipaddress.IPvXNetwork 182 | return [netaddr.IPNetwork(str(p)) for p in out_prefixes] 183 | 184 | 185 | def get_interface( 186 | device: Device, 187 | obj: Union[ 188 | str, 189 | IPv4Interface, 190 | IPv4Network, 191 | IPv6Address, 192 | IPv6Network, 193 | netaddr.IPNetwork, 194 | netaddr.IPAddress, 195 | IPAddress, 196 | Prefix, 197 | VLAN, 198 | ], 199 | include_vlans=True, 200 | ): 201 | if isinstance(obj, Prefix): 202 | obj = obj.prefix 203 | elif isinstance(obj, IPAddress): 204 | obj = obj.address 205 | 206 | if isinstance(obj, (str, netaddr.IPNetwork, netaddr.IPAddress)): 207 | obj = ip_interface(str(obj)) 208 | 209 | if isinstance(obj, VLAN): 210 | vlan_filter = Q(untagged_vlan=obj) | Q(tagged_vlans=obj) 211 | return device.interfaces.filter(vlan_filter).first() 212 | 213 | if include_vlans: 214 | # Get the vlan interface for this IP if the router has one 215 | vlan_interface = VLAN.objects.filter( 216 | interfaces_as_tagged__device=device, 217 | prefixes__prefix__net_contains_or_equals=str(obj), 218 | ).last() 219 | if vlan_interface: 220 | return vlan_interface 221 | 222 | if obj.network.max_prefixlen == obj.network.prefixlen: 223 | # A /32 or /128, so query based on the IP host 224 | query = dict(ip_addresses__address__net_host=str(obj)) 225 | else: 226 | # A subnet of some sort 227 | query = dict(ip_addresses__address__net_contained_or_equal=str(obj)) 228 | 229 | # Get the smallest matching subnet 230 | return ( 231 | device.interfaces.filter(**query) 232 | .order_by("ip_addresses__address__net_mask_length") 233 | .last() 234 | ) 235 | 236 | 237 | def get_prefix(ip_address, **extra_filters): 238 | return ( 239 | Prefix.objects.filter( 240 | prefix__net_contained_or_equal=str(ip_address), **extra_filters 241 | ) 242 | .order_by("prefix__net_mask_length") 243 | .last() 244 | ) 245 | 246 | 247 | def get_address(device: Device, interface: Union[Interface, VLAN], **extra_filters): 248 | if isinstance(interface, Interface): 249 | return interface.ip_addresses.filter(**extra_filters).first() 250 | else: 251 | vlan_prefixes = [str(p.prefix) for p in interface.prefixes.all()] 252 | vlan_prefixes = Cast(vlan_prefixes, output_field=ArrayField(IPAddressField())) 253 | return IPAddress.objects.filter( 254 | interface__device=device, 255 | address__net_contained_or_equal=Any(vlan_prefixes), 256 | **extra_filters, 257 | ).first() 258 | 259 | 260 | def orm_or(**filters): 261 | query = Q() 262 | for k, v in filters.items(): 263 | query |= Q(**{k: v}) 264 | return query 265 | 266 | 267 | def run_python_function(fn: str, *args, **kwargs): 268 | fn = import_string(fn) 269 | return fn(*args, **kwargs) 270 | -------------------------------------------------------------------------------- /netbox_routeros/templates/routeros/configured_device.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load render_table from django_tables2 %} 3 | {% load buttons %} 4 | {% load static %} 5 | {% load helpers %} 6 | {% load plugins %} 7 | 8 | {% block header %} 9 |
10 |
11 | 15 |
16 |
17 | 18 | {% if perms.routeros.apply_configureddevice %} 19 | {% if not object.problems %} 20 |
21 | {% csrf_token %} 22 | 23 | 27 |
28 | 29 | {% if object.last_config_fetched %} 30 |
32 | {% csrf_token %} 33 | 34 | 38 |
39 | {% endif %} 40 | {% endif %} 41 | 42 | {% endif %} 43 | 44 | {% if perms.routeros.change_configureddevice %} 45 | 47 | Edit 48 | 49 | {% endif %} 50 |
51 |
52 | 53 | 54 | {% endblock %} 55 | 56 | {% block content %} 57 |
58 |
59 |
60 |
61 |
62 |
63 | {% if object.problems %} 64 |
65 |
66 | Potential problems 67 |
68 |
69 |

70 | Some problems were detected with this device or your configuration. 71 | You will need to correct them before you can pull 72 | from or push to the device. 73 |

74 |
    75 | {% for probem in object.problems %} 76 |
  • {{ probem }}
  • 77 | {% endfor %} 78 |
79 |
80 |
81 | {% endif %} 82 | 83 |
84 |
85 | Device & configuration template 86 |
87 | 88 | 89 | 90 | 93 | 94 | 95 | 96 | 99 | 100 | 101 | 102 | 113 | 114 |
Device 91 | {{ object.device }} 92 |
Configuration template 97 | {{ object.configuration_template }} 98 |
Tenant 103 | {% if object.tenant %} 104 | {% if object.tenant.group %} 105 | {{ object.tenant.group }} 106 | / 107 | {% endif %} 108 | {{ object.tenant }} 109 | {% else %} 110 | None 111 | {% endif %} 112 |
115 |
116 | 117 | {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:configureddevice_list' %} 118 | 119 |
120 |
121 | Extra configuration 122 |
123 |
124 | {% if object.extra_configuration %} 125 |
{{ object.extra_configuration }}
126 | {% else %} 127 | No extra configuration set 128 | {% endif %} 129 |
130 |
131 | 132 | {% plugin_left_page object %} 133 |
134 |
135 | 136 |
137 |
138 | Configuration 139 |
140 |
141 | 156 | 157 | 158 |
159 |
160 |

161 | The bootstrap configuration is the initial configuration which must be 162 | run on new routers. This should be as minimal as possible, and its sole 163 | purpose is to make the router accessible to Netbox. 164 |

165 |
166 | {% if config_bootstrap %} 167 |
{{ config_bootstrap }}
168 |

169 | This can be changed by editing the configuration template with 170 | the slug of 'bootstrap' 171 |

172 | {% else %} 173 |

174 | No bootstrap config created. 175 | Create a configuration template with the slug 'bootstrap' 176 |

177 | {% endif %} 178 |
179 |
181 | {{ config_generated|safe }} 182 |
183 | 184 | {% if config_latest %} 185 |
186 | {{ config_latest|safe }} 187 |
188 | {% else %} 189 |
190 | 193 |
194 | {% endif %} 195 | 196 | {% if not object.last_config_fetched %} 197 |
198 | 202 |
203 | {% elif config_diff %} 204 |
206 | {{ config_diff|safe }} 207 |
208 | {% else %} 209 |
210 | 213 |
214 | {% endif %} 215 |
216 |
217 |
218 | 219 | 220 |
221 |
222 | Context: values 223 |
224 |
225 |
{{ context_values }}
226 |
227 |
228 | 229 |
230 |
231 | Context: functions 232 |
233 |
234 |
{{ context_functions }}
235 |
236 |
237 | 238 |
239 |
240 | Context: models 241 |
242 |
243 |
{{ context_models }}
244 |
245 |
246 |
247 |
248 |
249 |
250 | {% plugin_full_width_page object %} 251 |
252 |
253 |
254 |
255 |
256 |
257 | {% include 'secrets/inc/private_key_modal.html' %} 258 | {% endblock %} 259 | 260 | {% block javascript %} 261 | 262 | {% endblock %} 263 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aiohttp" 3 | version = "3.7.4.post0" 4 | description = "Async http client/server framework (asyncio)" 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [package.dependencies] 10 | async-timeout = ">=3.0,<4.0" 11 | attrs = ">=17.3.0" 12 | chardet = ">=2.0,<5.0" 13 | multidict = ">=4.5,<7.0" 14 | typing-extensions = ">=3.6.5" 15 | yarl = ">=1.0,<2.0" 16 | 17 | [package.extras] 18 | speedups = ["aiodns", "brotlipy", "cchardet"] 19 | 20 | [[package]] 21 | name = "appdirs" 22 | version = "1.4.4" 23 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 24 | category = "dev" 25 | optional = false 26 | python-versions = "*" 27 | 28 | [[package]] 29 | name = "async-timeout" 30 | version = "3.0.1" 31 | description = "Timeout context manager for asyncio programs" 32 | category = "dev" 33 | optional = false 34 | python-versions = ">=3.5.3" 35 | 36 | [[package]] 37 | name = "atomicwrites" 38 | version = "1.4.0" 39 | description = "Atomic file writes." 40 | category = "dev" 41 | optional = false 42 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 43 | 44 | [[package]] 45 | name = "attrs" 46 | version = "20.3.0" 47 | description = "Classes Without Boilerplate" 48 | category = "dev" 49 | optional = false 50 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 51 | 52 | [package.extras] 53 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 54 | docs = ["furo", "sphinx", "zope.interface"] 55 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 56 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 57 | 58 | [[package]] 59 | name = "black" 60 | version = "20.8b1" 61 | description = "The uncompromising code formatter." 62 | category = "dev" 63 | optional = false 64 | python-versions = ">=3.6" 65 | 66 | [package.dependencies] 67 | appdirs = "*" 68 | click = ">=7.1.2" 69 | mypy-extensions = ">=0.4.3" 70 | pathspec = ">=0.6,<1" 71 | regex = ">=2020.1.8" 72 | toml = ">=0.10.1" 73 | typed-ast = ">=1.4.0" 74 | typing-extensions = ">=3.7.4" 75 | 76 | [package.extras] 77 | colorama = ["colorama (>=0.4.3)"] 78 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 79 | 80 | [[package]] 81 | name = "cerberus" 82 | version = "1.3.2" 83 | description = "Lightweight, extensible schema and data validation tool for Python dictionaries." 84 | category = "dev" 85 | optional = false 86 | python-versions = ">=2.7" 87 | 88 | [[package]] 89 | name = "certifi" 90 | version = "2020.12.5" 91 | description = "Python package for providing Mozilla's CA Bundle." 92 | category = "dev" 93 | optional = false 94 | python-versions = "*" 95 | 96 | [[package]] 97 | name = "chardet" 98 | version = "4.0.0" 99 | description = "Universal encoding detector for Python 2 and 3" 100 | category = "dev" 101 | optional = false 102 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 103 | 104 | [[package]] 105 | name = "click" 106 | version = "7.1.2" 107 | description = "Composable command line interface toolkit" 108 | category = "dev" 109 | optional = false 110 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 111 | 112 | [[package]] 113 | name = "colorama" 114 | version = "0.4.4" 115 | description = "Cross-platform colored terminal text." 116 | category = "dev" 117 | optional = false 118 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 119 | 120 | [[package]] 121 | name = "dephell" 122 | version = "0.8.3" 123 | description = "Dependency resolution for Python" 124 | category = "dev" 125 | optional = false 126 | python-versions = ">=3.6" 127 | 128 | [package.dependencies] 129 | aiohttp = "*" 130 | attrs = ">=19.2.0" 131 | cerberus = ">=1.3" 132 | certifi = "*" 133 | dephell-archive = ">=0.1.5" 134 | dephell-argparse = ">=0.1.1" 135 | dephell-changelogs = "*" 136 | dephell-discover = ">=0.2.6" 137 | dephell-licenses = ">=0.1.6" 138 | dephell-links = ">=0.1.4" 139 | dephell-markers = ">=1.0.0" 140 | dephell-pythons = ">=0.1.11" 141 | dephell-setuptools = ">=0.2.1" 142 | dephell-shells = ">=0.1.3" 143 | dephell-specifier = ">=0.1.7" 144 | dephell-venvs = ">=0.1.16" 145 | dephell-versioning = "*" 146 | jinja2 = "*" 147 | m2r = "*" 148 | packaging = "*" 149 | requests = "*" 150 | "ruamel.yaml" = "*" 151 | tomlkit = "*" 152 | yaspin = "*" 153 | 154 | [package.extras] 155 | full = ["aiofiles", "appdirs", "autopep8", "bowler", "colorama", "docker", "dockerpty", "fissix", "graphviz", "html5lib", "pygments", "python-gnupg", "tabulate", "yapf"] 156 | tests = ["aioresponses", "pytest", "requests-mock"] 157 | dev = ["aioresponses", "alabaster", "flake8-isort", "isort", "pygments-github-lexers", "pytest", "recommonmark", "requests-mock", "sphinx"] 158 | docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"] 159 | 160 | [[package]] 161 | name = "dephell-archive" 162 | version = "0.1.7" 163 | description = "pathlib for archives" 164 | category = "dev" 165 | optional = false 166 | python-versions = ">=3.6" 167 | 168 | [package.dependencies] 169 | attrs = "*" 170 | 171 | [[package]] 172 | name = "dephell-argparse" 173 | version = "0.1.3" 174 | description = "Argparse on steroids: groups, commands, colors." 175 | category = "dev" 176 | optional = false 177 | python-versions = ">=3.5" 178 | 179 | [[package]] 180 | name = "dephell-changelogs" 181 | version = "0.0.1" 182 | description = "Find changelog for github repository, local dir, parse changelog" 183 | category = "dev" 184 | optional = false 185 | python-versions = ">=3.5" 186 | 187 | [package.dependencies] 188 | requests = "*" 189 | 190 | [package.extras] 191 | dev = ["pytest", "pytest-xdist"] 192 | 193 | [[package]] 194 | name = "dephell-discover" 195 | version = "0.2.10" 196 | description = "Find project modules and data files (packages and package_data for setup.py)." 197 | category = "dev" 198 | optional = false 199 | python-versions = ">=3.5" 200 | 201 | [package.dependencies] 202 | attrs = "*" 203 | 204 | [[package]] 205 | name = "dephell-licenses" 206 | version = "0.1.7" 207 | description = "Get info about OSS licenses" 208 | category = "dev" 209 | optional = false 210 | python-versions = ">=3.5" 211 | 212 | [package.dependencies] 213 | attrs = "*" 214 | requests = "*" 215 | 216 | [[package]] 217 | name = "dephell-links" 218 | version = "0.1.5" 219 | description = "Parse dependency links" 220 | category = "dev" 221 | optional = false 222 | python-versions = ">=3.5" 223 | 224 | [package.dependencies] 225 | attrs = "*" 226 | 227 | [[package]] 228 | name = "dephell-markers" 229 | version = "1.0.3" 230 | description = "Work with environment markers (PEP-496)" 231 | category = "dev" 232 | optional = false 233 | python-versions = ">=3.5" 234 | 235 | [package.dependencies] 236 | attrs = "*" 237 | dephell-specifier = "*" 238 | packaging = "*" 239 | 240 | [[package]] 241 | name = "dephell-pythons" 242 | version = "0.1.15" 243 | description = "Work with python versions" 244 | category = "dev" 245 | optional = false 246 | python-versions = ">=3.6" 247 | 248 | [package.dependencies] 249 | attrs = "*" 250 | dephell-specifier = "*" 251 | packaging = "*" 252 | 253 | [[package]] 254 | name = "dephell-setuptools" 255 | version = "0.2.4" 256 | description = "Read metainfo from setup.py" 257 | category = "dev" 258 | optional = false 259 | python-versions = ">=3.5" 260 | 261 | [package.extras] 262 | dev = ["mypy", "pkginfo", "pytest"] 263 | 264 | [[package]] 265 | name = "dephell-shells" 266 | version = "0.1.5" 267 | description = "activate virtual environment for current shell" 268 | category = "dev" 269 | optional = false 270 | python-versions = ">=3.6" 271 | 272 | [package.dependencies] 273 | attrs = "*" 274 | pexpect = "*" 275 | shellingham = "*" 276 | 277 | [[package]] 278 | name = "dephell-specifier" 279 | version = "0.2.2" 280 | description = "Work with version specifiers." 281 | category = "dev" 282 | optional = false 283 | python-versions = ">=3.6" 284 | 285 | [package.dependencies] 286 | packaging = ">=17.1" 287 | 288 | [[package]] 289 | name = "dephell-venvs" 290 | version = "0.1.18" 291 | description = "Manage virtual environments" 292 | category = "dev" 293 | optional = false 294 | python-versions = ">=3.5" 295 | 296 | [package.dependencies] 297 | attrs = "*" 298 | dephell-pythons = "*" 299 | requests = "*" 300 | 301 | [[package]] 302 | name = "dephell-versioning" 303 | version = "0.1.2" 304 | description = "Library for bumping project version like a pro" 305 | category = "dev" 306 | optional = false 307 | python-versions = ">=3.6" 308 | 309 | [package.dependencies] 310 | packaging = "*" 311 | 312 | [[package]] 313 | name = "docutils" 314 | version = "0.16" 315 | description = "Docutils -- Python Documentation Utilities" 316 | category = "dev" 317 | optional = false 318 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 319 | 320 | [[package]] 321 | name = "idna" 322 | version = "2.10" 323 | description = "Internationalized Domain Names in Applications (IDNA)" 324 | category = "dev" 325 | optional = false 326 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 327 | 328 | [[package]] 329 | name = "jinja2" 330 | version = "2.11.3" 331 | description = "A very fast and expressive template engine." 332 | category = "main" 333 | optional = false 334 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 335 | 336 | [package.dependencies] 337 | MarkupSafe = ">=0.23" 338 | 339 | [package.extras] 340 | i18n = ["Babel (>=0.8)"] 341 | 342 | [[package]] 343 | name = "m2r" 344 | version = "0.2.1" 345 | description = "Markdown and reStructuredText in a single file." 346 | category = "dev" 347 | optional = false 348 | python-versions = "*" 349 | 350 | [package.dependencies] 351 | docutils = "*" 352 | mistune = "*" 353 | 354 | [[package]] 355 | name = "markupsafe" 356 | version = "1.1.1" 357 | description = "Safely add untrusted strings to HTML/XML markup." 358 | category = "main" 359 | optional = false 360 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" 361 | 362 | [[package]] 363 | name = "mistune" 364 | version = "0.8.4" 365 | description = "The fastest markdown parser in pure Python" 366 | category = "dev" 367 | optional = false 368 | python-versions = "*" 369 | 370 | [[package]] 371 | name = "more-itertools" 372 | version = "8.7.0" 373 | description = "More routines for operating on iterables, beyond itertools" 374 | category = "dev" 375 | optional = false 376 | python-versions = ">=3.5" 377 | 378 | [[package]] 379 | name = "multidict" 380 | version = "5.1.0" 381 | description = "multidict implementation" 382 | category = "dev" 383 | optional = false 384 | python-versions = ">=3.6" 385 | 386 | [[package]] 387 | name = "mypy-extensions" 388 | version = "0.4.3" 389 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 390 | category = "dev" 391 | optional = false 392 | python-versions = "*" 393 | 394 | [[package]] 395 | name = "packaging" 396 | version = "20.9" 397 | description = "Core utilities for Python packages" 398 | category = "dev" 399 | optional = false 400 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 401 | 402 | [package.dependencies] 403 | pyparsing = ">=2.0.2" 404 | 405 | [[package]] 406 | name = "pathspec" 407 | version = "0.8.1" 408 | description = "Utility library for gitignore style pattern matching of file paths." 409 | category = "dev" 410 | optional = false 411 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 412 | 413 | [[package]] 414 | name = "pexpect" 415 | version = "4.8.0" 416 | description = "Pexpect allows easy control of interactive console applications." 417 | category = "dev" 418 | optional = false 419 | python-versions = "*" 420 | 421 | [package.dependencies] 422 | ptyprocess = ">=0.5" 423 | 424 | [[package]] 425 | name = "pluggy" 426 | version = "0.13.1" 427 | description = "plugin and hook calling mechanisms for python" 428 | category = "dev" 429 | optional = false 430 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 431 | 432 | [package.extras] 433 | dev = ["pre-commit", "tox"] 434 | 435 | [[package]] 436 | name = "ptyprocess" 437 | version = "0.7.0" 438 | description = "Run a subprocess in a pseudo terminal" 439 | category = "dev" 440 | optional = false 441 | python-versions = "*" 442 | 443 | [[package]] 444 | name = "py" 445 | version = "1.10.0" 446 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 447 | category = "dev" 448 | optional = false 449 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 450 | 451 | [[package]] 452 | name = "pyparsing" 453 | version = "2.4.7" 454 | description = "Python parsing module" 455 | category = "dev" 456 | optional = false 457 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 458 | 459 | [[package]] 460 | name = "pytest" 461 | version = "5.4.3" 462 | description = "pytest: simple powerful testing with Python" 463 | category = "dev" 464 | optional = false 465 | python-versions = ">=3.5" 466 | 467 | [package.dependencies] 468 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 469 | attrs = ">=17.4.0" 470 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 471 | more-itertools = ">=4.0.0" 472 | packaging = "*" 473 | pluggy = ">=0.12,<1.0" 474 | py = ">=1.5.0" 475 | wcwidth = "*" 476 | 477 | [package.extras] 478 | checkqa-mypy = ["mypy (==v0.761)"] 479 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 480 | 481 | [[package]] 482 | name = "regex" 483 | version = "2021.3.17" 484 | description = "Alternative regular expression module, to replace re." 485 | category = "dev" 486 | optional = false 487 | python-versions = "*" 488 | 489 | [[package]] 490 | name = "requests" 491 | version = "2.25.1" 492 | description = "Python HTTP for Humans." 493 | category = "dev" 494 | optional = false 495 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 496 | 497 | [package.dependencies] 498 | certifi = ">=2017.4.17" 499 | chardet = ">=3.0.2,<5" 500 | idna = ">=2.5,<3" 501 | urllib3 = ">=1.21.1,<1.27" 502 | 503 | [package.extras] 504 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] 505 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 506 | 507 | [[package]] 508 | name = "ruamel.yaml" 509 | version = "0.16.13" 510 | description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" 511 | category = "dev" 512 | optional = false 513 | python-versions = "*" 514 | 515 | [package.dependencies] 516 | "ruamel.yaml.clib" = {version = ">=0.1.2", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.10\""} 517 | 518 | [package.extras] 519 | docs = ["ryd"] 520 | jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] 521 | 522 | [[package]] 523 | name = "ruamel.yaml.clib" 524 | version = "0.2.2" 525 | description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" 526 | category = "dev" 527 | optional = false 528 | python-versions = "*" 529 | 530 | [[package]] 531 | name = "shellingham" 532 | version = "1.4.0" 533 | description = "Tool to Detect Surrounding Shell" 534 | category = "dev" 535 | optional = false 536 | python-versions = "!=3.0,!=3.1,!=3.2,!=3.3,>=2.6" 537 | 538 | [[package]] 539 | name = "toml" 540 | version = "0.10.2" 541 | description = "Python Library for Tom's Obvious, Minimal Language" 542 | category = "dev" 543 | optional = false 544 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 545 | 546 | [[package]] 547 | name = "tomlkit" 548 | version = "0.7.0" 549 | description = "Style preserving TOML library" 550 | category = "dev" 551 | optional = false 552 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 553 | 554 | [[package]] 555 | name = "typed-ast" 556 | version = "1.4.2" 557 | description = "a fork of Python 2 and 3 ast modules with type comment support" 558 | category = "dev" 559 | optional = false 560 | python-versions = "*" 561 | 562 | [[package]] 563 | name = "typing-extensions" 564 | version = "3.7.4.3" 565 | description = "Backported and Experimental Type Hints for Python 3.5+" 566 | category = "dev" 567 | optional = false 568 | python-versions = "*" 569 | 570 | [[package]] 571 | name = "urllib3" 572 | version = "1.26.4" 573 | description = "HTTP library with thread-safe connection pooling, file post, and more." 574 | category = "dev" 575 | optional = false 576 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 577 | 578 | [package.extras] 579 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 580 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 581 | brotli = ["brotlipy (>=0.6.0)"] 582 | 583 | [[package]] 584 | name = "wcwidth" 585 | version = "0.2.5" 586 | description = "Measures the displayed width of unicode strings in a terminal" 587 | category = "dev" 588 | optional = false 589 | python-versions = "*" 590 | 591 | [[package]] 592 | name = "yarl" 593 | version = "1.6.3" 594 | description = "Yet another URL library" 595 | category = "dev" 596 | optional = false 597 | python-versions = ">=3.6" 598 | 599 | [package.dependencies] 600 | idna = ">=2.0" 601 | multidict = ">=4.0" 602 | 603 | [[package]] 604 | name = "yaspin" 605 | version = "1.4.1" 606 | description = "Yet Another Terminal Spinner" 607 | category = "dev" 608 | optional = false 609 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 610 | 611 | [metadata] 612 | lock-version = "1.1" 613 | python-versions = "^3.8" 614 | content-hash = "18f371f1bb125823af7bfed39e62b39aab3b9252e8524f7baa3ac120cb3cff2a" 615 | 616 | [metadata.files] 617 | aiohttp = [ 618 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5"}, 619 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8"}, 620 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"}, 621 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290"}, 622 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f"}, 623 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809"}, 624 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe"}, 625 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-win32.whl", hash = "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287"}, 626 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc"}, 627 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87"}, 628 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0"}, 629 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970"}, 630 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f"}, 631 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde"}, 632 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c"}, 633 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8"}, 634 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-win32.whl", hash = "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f"}, 635 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5"}, 636 | {file = "aiohttp-3.7.4.post0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf"}, 637 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df"}, 638 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213"}, 639 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4"}, 640 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009"}, 641 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5"}, 642 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013"}, 643 | {file = "aiohttp-3.7.4.post0-cp38-cp38-win32.whl", hash = "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16"}, 644 | {file = "aiohttp-3.7.4.post0-cp38-cp38-win_amd64.whl", hash = "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5"}, 645 | {file = "aiohttp-3.7.4.post0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b"}, 646 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd"}, 647 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439"}, 648 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22"}, 649 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a"}, 650 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb"}, 651 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb"}, 652 | {file = "aiohttp-3.7.4.post0-cp39-cp39-win32.whl", hash = "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9"}, 653 | {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, 654 | {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, 655 | ] 656 | appdirs = [ 657 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 658 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 659 | ] 660 | async-timeout = [ 661 | {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, 662 | {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, 663 | ] 664 | atomicwrites = [ 665 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 666 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 667 | ] 668 | attrs = [ 669 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 670 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 671 | ] 672 | black = [ 673 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 674 | ] 675 | cerberus = [ 676 | {file = "Cerberus-1.3.2.tar.gz", hash = "sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"}, 677 | ] 678 | certifi = [ 679 | {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, 680 | {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, 681 | ] 682 | chardet = [ 683 | {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, 684 | {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, 685 | ] 686 | click = [ 687 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 688 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 689 | ] 690 | colorama = [ 691 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 692 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 693 | ] 694 | dephell = [ 695 | {file = "dephell-0.8.3-py3-none-any.whl", hash = "sha256:3ca3661e2a353b5c67c77034b69b379e360d4c70ce562e8161db32d39064be5a"}, 696 | {file = "dephell-0.8.3.tar.gz", hash = "sha256:a9fcc528a0c6f9f5d721292bdf846e5338e4dca7cd6fef1551fbe71564dfe61e"}, 697 | ] 698 | dephell-archive = [ 699 | {file = "dephell-archive-0.1.7.tar.gz", hash = "sha256:bb263492a7d430f9e04cef9a0237b7752cc797ab364bf35e70196af09c73ea37"}, 700 | {file = "dephell_archive-0.1.7-py3-none-any.whl", hash = "sha256:64a688dd8acb780f7d56cdae4622fa01d1e5910fd65788974b3f70fa9a1e517a"}, 701 | ] 702 | dephell-argparse = [ 703 | {file = "dephell_argparse-0.1.3-py3-none-any.whl", hash = "sha256:e37a52c511b53e9d6107b606088664754b4b4d9e734578b333e68c46e4ab45b7"}, 704 | {file = "dephell_argparse-0.1.3.tar.gz", hash = "sha256:2ab9b2441f808bb11c338c4849d22ded898cde8325946800ac9e39d2b138735d"}, 705 | ] 706 | dephell-changelogs = [ 707 | {file = "dephell_changelogs-0.0.1-py3-none-any.whl", hash = "sha256:963d31346790a3aacc3409bbc7cb0b44cdc0e29c167eec196fb49a131c3035b8"}, 708 | {file = "dephell_changelogs-0.0.1.tar.gz", hash = "sha256:e639a3d08d389e22fbac0cc64181dbe93c4b4ba9f0134e273e6dd3e26ae70b21"}, 709 | ] 710 | dephell-discover = [ 711 | {file = "dephell_discover-0.2.10-py3-none-any.whl", hash = "sha256:abf190e9707d4a88f14e91be1f80e996e195b20b5400da2362e98cf19e59a1e4"}, 712 | {file = "dephell_discover-0.2.10.tar.gz", hash = "sha256:a2ad414e5e0fe16c82c537d6a3198afd9818c0c010760eccb23e2d60e5b66df6"}, 713 | ] 714 | dephell-licenses = [ 715 | {file = "dephell-licenses-0.1.7.tar.gz", hash = "sha256:f175cec822a32bda5b56442f48dae39efbb5c3851275ecd41cfd7e849ddd2ea6"}, 716 | {file = "dephell_licenses-0.1.7-py3-none-any.whl", hash = "sha256:b0b6c93779c4a8d9a82710ef2d5d0fab72e013f335962dc7363831af48570db5"}, 717 | ] 718 | dephell-links = [ 719 | {file = "dephell_links-0.1.5-py3-none-any.whl", hash = "sha256:a86a08fb42da63d903ae3fee9f9e2491be602321204c0df5b53e33cb19ac4dec"}, 720 | {file = "dephell_links-0.1.5.tar.gz", hash = "sha256:28d694142e2827a59d2c301e7185afb52fb8acdb950b1da38308d69e43418eaa"}, 721 | ] 722 | dephell-markers = [ 723 | {file = "dephell_markers-1.0.3-py3-none-any.whl", hash = "sha256:54ad6807b087d6c9171efc2d94eda3a9e3cad7ea2ca4b27186789d455a6c730a"}, 724 | {file = "dephell_markers-1.0.3.tar.gz", hash = "sha256:525e17914e705acf8652dd8681fccdec912432a747d8def4720f49416817f2d4"}, 725 | ] 726 | dephell-pythons = [ 727 | {file = "dephell_pythons-0.1.15-py3-none-any.whl", hash = "sha256:03132d083d0369683b87d03767dc0f0f88b8d92d5cf19cfdb36d8845b70ecdb2"}, 728 | {file = "dephell_pythons-0.1.15.tar.gz", hash = "sha256:804c29afa2147322aa23e791f591d0204fd1e9983afa7d91e1d1452fc7be1c5c"}, 729 | ] 730 | dephell-setuptools = [ 731 | {file = "dephell_setuptools-0.2.4-py3-none-any.whl", hash = "sha256:275f9bec4b276614939ac9efa732a0ae6aef06ae63e3b62371d0f15a19299208"}, 732 | {file = "dephell_setuptools-0.2.4.tar.gz", hash = "sha256:663629e1ebf7b20bf7e372ee2a2e7ebf1a15aeb3bc6d46ad32e1bcb21044ca29"}, 733 | ] 734 | dephell-shells = [ 735 | {file = "dephell_shells-0.1.5-py3-none-any.whl", hash = "sha256:3bdb8aba72640c51259dc5cb0ee40c4cd948cb644e5ceedd7e725766575a5225"}, 736 | {file = "dephell_shells-0.1.5.tar.gz", hash = "sha256:77150b732db135d436f41c2c6f12694e6058a8609214117ee80f6c40234ac2d5"}, 737 | ] 738 | dephell-specifier = [ 739 | {file = "dephell_specifier-0.2.2-py3-none-any.whl", hash = "sha256:021ad2ab3f3f130b5ac5cefa554c12f0e2dbb35d5d52ad9474a1f2c8b420f7c2"}, 740 | {file = "dephell_specifier-0.2.2.tar.gz", hash = "sha256:b5ec6409a1916980c4861da2cb7538246555bff4b95bef2c952c56bd19eb2de6"}, 741 | ] 742 | dephell-venvs = [ 743 | {file = "dephell_venvs-0.1.18-py3-none-any.whl", hash = "sha256:bd3ad440702aa9a9dc21bbab9633537fa395296d40451280d40046d9e3372e6d"}, 744 | {file = "dephell_venvs-0.1.18.tar.gz", hash = "sha256:c7307291b754edba325ab27edeb05d85ee4dd2f1487c48872a1ebfc372bf7a2e"}, 745 | ] 746 | dephell-versioning = [ 747 | {file = "dephell_versioning-0.1.2-py3-none-any.whl", hash = "sha256:28f611bd3ec1644e3d6972f901b9aa67a1fe2ed3fe57566f82afd9c43f5a335a"}, 748 | {file = "dephell_versioning-0.1.2.tar.gz", hash = "sha256:9ba7636704af7bd64af5a64ab8efb482c8b0bf4868699722f5e2647763edf8e5"}, 749 | ] 750 | docutils = [ 751 | {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, 752 | {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, 753 | ] 754 | idna = [ 755 | {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, 756 | {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, 757 | ] 758 | jinja2 = [ 759 | {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, 760 | {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, 761 | ] 762 | m2r = [ 763 | {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, 764 | ] 765 | markupsafe = [ 766 | {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, 767 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, 768 | {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, 769 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, 770 | {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, 771 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, 772 | {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, 773 | {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, 774 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, 775 | {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, 776 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, 777 | {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, 778 | {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, 779 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, 780 | {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, 781 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, 782 | {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, 783 | {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, 784 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, 785 | {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, 786 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, 787 | {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, 788 | {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, 789 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, 790 | {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, 791 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, 792 | {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, 793 | {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, 794 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, 795 | {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, 796 | {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, 797 | {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, 798 | {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, 799 | ] 800 | mistune = [ 801 | {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, 802 | {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, 803 | ] 804 | more-itertools = [ 805 | {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, 806 | {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, 807 | ] 808 | multidict = [ 809 | {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, 810 | {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, 811 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, 812 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, 813 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, 814 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, 815 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, 816 | {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, 817 | {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, 818 | {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, 819 | {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, 820 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, 821 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, 822 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, 823 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, 824 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, 825 | {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, 826 | {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, 827 | {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, 828 | {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, 829 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, 830 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, 831 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, 832 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, 833 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, 834 | {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, 835 | {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, 836 | {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, 837 | {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, 838 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, 839 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, 840 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, 841 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, 842 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, 843 | {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, 844 | {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, 845 | {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, 846 | ] 847 | mypy-extensions = [ 848 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 849 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 850 | ] 851 | packaging = [ 852 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 853 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 854 | ] 855 | pathspec = [ 856 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 857 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 858 | ] 859 | pexpect = [ 860 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 861 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 862 | ] 863 | pluggy = [ 864 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 865 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 866 | ] 867 | ptyprocess = [ 868 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 869 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 870 | ] 871 | py = [ 872 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 873 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 874 | ] 875 | pyparsing = [ 876 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 877 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 878 | ] 879 | pytest = [ 880 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 881 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 882 | ] 883 | regex = [ 884 | {file = "regex-2021.3.17-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6"}, 885 | {file = "regex-2021.3.17-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0"}, 886 | {file = "regex-2021.3.17-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c"}, 887 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa"}, 888 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14"}, 889 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f"}, 890 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd"}, 891 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e"}, 892 | {file = "regex-2021.3.17-cp36-cp36m-win32.whl", hash = "sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3"}, 893 | {file = "regex-2021.3.17-cp36-cp36m-win_amd64.whl", hash = "sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5"}, 894 | {file = "regex-2021.3.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90"}, 895 | {file = "regex-2021.3.17-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f"}, 896 | {file = "regex-2021.3.17-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689"}, 897 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932"}, 898 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa"}, 899 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df"}, 900 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce"}, 901 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643"}, 902 | {file = "regex-2021.3.17-cp37-cp37m-win32.whl", hash = "sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be"}, 903 | {file = "regex-2021.3.17-cp37-cp37m-win_amd64.whl", hash = "sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb"}, 904 | {file = "regex-2021.3.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18"}, 905 | {file = "regex-2021.3.17-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5"}, 906 | {file = "regex-2021.3.17-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe"}, 907 | {file = "regex-2021.3.17-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578"}, 908 | {file = "regex-2021.3.17-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d"}, 909 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5"}, 910 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c"}, 911 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d"}, 912 | {file = "regex-2021.3.17-cp38-cp38-win32.whl", hash = "sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf"}, 913 | {file = "regex-2021.3.17-cp38-cp38-win_amd64.whl", hash = "sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa"}, 914 | {file = "regex-2021.3.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3"}, 915 | {file = "regex-2021.3.17-cp39-cp39-manylinux1_i686.whl", hash = "sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a"}, 916 | {file = "regex-2021.3.17-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c"}, 917 | {file = "regex-2021.3.17-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139"}, 918 | {file = "regex-2021.3.17-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e"}, 919 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba"}, 920 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106"}, 921 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7"}, 922 | {file = "regex-2021.3.17-cp39-cp39-win32.whl", hash = "sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd"}, 923 | {file = "regex-2021.3.17-cp39-cp39-win_amd64.whl", hash = "sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38"}, 924 | {file = "regex-2021.3.17.tar.gz", hash = "sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68"}, 925 | ] 926 | requests = [ 927 | {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, 928 | {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, 929 | ] 930 | "ruamel.yaml" = [ 931 | {file = "ruamel.yaml-0.16.13-py2.py3-none-any.whl", hash = "sha256:64b06e7873eb8e1125525ecef7345447d786368cadca92a7cd9b59eae62e95a3"}, 932 | {file = "ruamel.yaml-0.16.13.tar.gz", hash = "sha256:bb48c514222702878759a05af96f4b7ecdba9b33cd4efcf25c86b882cef3a942"}, 933 | ] 934 | "ruamel.yaml.clib" = [ 935 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc"}, 936 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1"}, 937 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win32.whl", hash = "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7"}, 938 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-win_amd64.whl", hash = "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f"}, 939 | {file = "ruamel.yaml.clib-0.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2"}, 940 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026"}, 941 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b"}, 942 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win32.whl", hash = "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f"}, 943 | {file = "ruamel.yaml.clib-0.2.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62"}, 944 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c"}, 945 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988"}, 946 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win32.whl", hash = "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2"}, 947 | {file = "ruamel.yaml.clib-0.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91"}, 948 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6"}, 949 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e"}, 950 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win32.whl", hash = "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6"}, 951 | {file = "ruamel.yaml.clib-0.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5"}, 952 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0"}, 953 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99"}, 954 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win32.whl", hash = "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1"}, 955 | {file = "ruamel.yaml.clib-0.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b"}, 956 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a"}, 957 | {file = "ruamel.yaml.clib-0.2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5"}, 958 | {file = "ruamel.yaml.clib-0.2.2.tar.gz", hash = "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7"}, 959 | ] 960 | shellingham = [ 961 | {file = "shellingham-1.4.0-py2.py3-none-any.whl", hash = "sha256:536b67a0697f2e4af32ab176c00a50ac2899c5a05e0d8e2dadac8e58888283f9"}, 962 | {file = "shellingham-1.4.0.tar.gz", hash = "sha256:4855c2458d6904829bd34c299f11fdeed7cfefbf8a2c522e4caea6cd76b3171e"}, 963 | ] 964 | toml = [ 965 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 966 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 967 | ] 968 | tomlkit = [ 969 | {file = "tomlkit-0.7.0-py2.py3-none-any.whl", hash = "sha256:6babbd33b17d5c9691896b0e68159215a9387ebfa938aa3ac42f4a4beeb2b831"}, 970 | {file = "tomlkit-0.7.0.tar.gz", hash = "sha256:ac57f29693fab3e309ea789252fcce3061e19110085aa31af5446ca749325618"}, 971 | ] 972 | typed-ast = [ 973 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, 974 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, 975 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, 976 | {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, 977 | {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, 978 | {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, 979 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, 980 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, 981 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, 982 | {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, 983 | {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, 984 | {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, 985 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, 986 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, 987 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, 988 | {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, 989 | {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, 990 | {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, 991 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, 992 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, 993 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, 994 | {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, 995 | {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, 996 | {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, 997 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, 998 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, 999 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, 1000 | {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, 1001 | {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, 1002 | {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, 1003 | ] 1004 | typing-extensions = [ 1005 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 1006 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 1007 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 1008 | ] 1009 | urllib3 = [ 1010 | {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, 1011 | {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, 1012 | ] 1013 | wcwidth = [ 1014 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 1015 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 1016 | ] 1017 | yarl = [ 1018 | {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, 1019 | {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, 1020 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, 1021 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, 1022 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, 1023 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, 1024 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, 1025 | {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, 1026 | {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, 1027 | {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, 1028 | {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, 1029 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, 1030 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, 1031 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, 1032 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, 1033 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, 1034 | {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, 1035 | {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, 1036 | {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, 1037 | {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, 1038 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, 1039 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, 1040 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, 1041 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, 1042 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, 1043 | {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, 1044 | {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, 1045 | {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, 1046 | {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, 1047 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, 1048 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, 1049 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, 1050 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, 1051 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, 1052 | {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, 1053 | {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, 1054 | {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, 1055 | ] 1056 | yaspin = [ 1057 | {file = "yaspin-1.4.1-py2.py3-none-any.whl", hash = "sha256:5b98d7620b8d9b7c74106fa1200c76b261664951a212fc443154c3b6823888a4"}, 1058 | {file = "yaspin-1.4.1.tar.gz", hash = "sha256:53f398eeeff430d9b686ce2f104f6b0fa0e2d165bafc7b7e4aa4432ec8d23582"}, 1059 | ] 1060 | --------------------------------------------------------------------------------