1
0
mirror of https://github.com/checktheroads/hyperglass synced 2024-05-11 05:55:08 +00:00

implement generic http client; remove hyperglass-agent connection handler

This commit is contained in:
thatmattlove
2021-11-07 01:19:29 -07:00
parent c8f678f766
commit 55f8a62fb0
11 changed files with 362 additions and 243 deletions

View File

@@ -80,4 +80,5 @@ DRIVER_MAP = {
"bird_legacy": "hyperglass_agent",
"bird": "netmiko",
"frr": "netmiko",
"http": "hyperglass_http_client",
}

View File

@@ -1,12 +1,12 @@
"""Individual transport driver classes & subclasses."""
# Local
from .agent import AgentConnection
from ._common import Connection
from .http_client import HttpClient
from .ssh_netmiko import NetmikoConnection
__all__ = (
"AgentConnection",
"Connection",
"HttpClient",
"NetmikoConnection",
)

View File

@@ -1,124 +0,0 @@
"""Execute validated & constructed query on device.
Accepts input from front end application, validates the input and
returns errors if input is invalid. Passes validated parameters to
construct.py, which is used to build & run the Netmiko connections or
hyperglass-frr API calls, returns the output back to the front end.
"""
# Standard Library
from ssl import CertificateError
from typing import TYPE_CHECKING, Iterable
# Third Party
import httpx
# Project
from hyperglass.log import log
from hyperglass.util import parse_exception
from hyperglass.state import use_state
from hyperglass.encode import jwt_decode, jwt_encode
from hyperglass.exceptions.public import RestError, ResponseEmpty
# Local
from ._common import Connection
if TYPE_CHECKING:
# Project
from hyperglass.compat._sshtunnel import SSHTunnelForwarder
class AgentConnection(Connection):
"""Connect to target device via hyperglass-agent."""
def setup_proxy(self: "Connection") -> "SSHTunnelForwarder":
"""Return a preconfigured sshtunnel.SSHTunnelForwarder instance."""
raise NotImplementedError("AgentConnection does not implement an SSH proxy.")
async def collect(self) -> Iterable: # noqa: C901
"""Connect to a device running hyperglass-agent via HTTP."""
log.debug("Query parameters: {}", self.query)
params = use_state("params")
client_params = {
"headers": {"Content-Type": "application/json"},
"timeout": params.request_timeout,
}
if self.device.ssl is not None and self.device.ssl.enable:
with self.device.ssl.cert.open("r") as file:
cert = file.read()
if not cert:
raise RestError(
"SSL Certificate for device {d} has not been imported",
level="danger",
d=self.device.name,
)
http_protocol = "https"
client_params.update({"verify": str(self.device.ssl.cert)})
log.debug(
(
f"Using {str(self.device.ssl.cert)} to validate connection "
f"to {self.device.name}"
)
)
else:
http_protocol = "http"
endpoint = "{protocol}://{address}:{port}/query/".format(
protocol=http_protocol, address=self.device._target, port=self.device.port
)
log.debug("URL endpoint: {}", endpoint)
try:
async with httpx.AsyncClient(**client_params) as http_client:
responses = ()
for query in self.query:
encoded_query = await jwt_encode(
payload=query,
secret=self.device.credential.password.get_secret_value(),
duration=params.request_timeout,
)
log.debug("Encoded JWT: {}", encoded_query)
raw_response = await http_client.post(endpoint, json={"encoded": encoded_query})
log.debug("HTTP status code: {}", raw_response.status_code)
raw = raw_response.text
log.debug("Raw Response:\n{}", raw)
if raw_response.status_code == 200:
decoded = await jwt_decode(
payload=raw_response.json()["encoded"],
secret=self.device.credential.password.get_secret_value(),
)
log.debug("Decoded Response:\n{}", decoded)
responses += (decoded,)
elif raw_response.status_code == 204:
raise ResponseEmpty(query=self.query_data)
else:
log.error(raw_response.text)
except httpx.exceptions.HTTPError as rest_error:
msg = parse_exception(rest_error)
raise RestError(error=httpx.exceptions.HTTPError(msg), device=self.device)
except OSError as ose:
raise RestError(error=ose, device=self.device)
except CertificateError as cert_error:
msg = parse_exception(cert_error)
raise RestError(error=CertificateError(cert_error), device=self.device)
if raw_response.status_code != 200:
raise RestError(
error=ConnectionError(f"Response code {raw_response.status_code}"),
device=self.device,
)
if not responses:
raise ResponseEmpty(query=self.query_data)
return responses

View File

@@ -0,0 +1,122 @@
"""Interact with an http-based device."""
# Standard Library
import typing as t
# Third Party
import httpx
# Project
from hyperglass.util import get_fmt_keys
from hyperglass.exceptions.public import (
AuthError,
RestError,
DeviceTimeout,
ResponseEmpty,
)
# Local
from ._common import Connection
if t.TYPE_CHECKING:
# Project
from hyperglass.models.api import Query
from hyperglass.models.config.devices import Device
from hyperglass.models.config.http_client import HttpConfiguration
class HttpClient(Connection):
"""Interact with an http-based device."""
config: "HttpConfiguration"
client: httpx.AsyncClient
def __init__(self, device: "Device", query_data: "Query") -> None:
"""Initialize base connection and set http config & client."""
super().__init__(device, query_data)
self.config = device.http
self.client = self.config.create_client(device=device)
def setup_proxy(self: "Connection"):
"""HTTP Client does not support SSH proxies."""
raise NotImplementedError("HTTP Client does not support SSH proxies.")
def _query_params(self) -> t.Dict[str, str]:
if self.config.query is None:
return {
self.config._attribute_map.query_target: self.query_data.query_target,
self.config._attribute_map.query_location: self.query_data.query_location,
self.config._attribute_map.query_type: self.query_data.query_type,
}
elif isinstance(self.config.query, t.Dict):
return {
key: value.format(
**{
str(v): str(getattr(self.query_data, k, None))
for k, v in self.config.attribute_map.dict().items()
if v in get_fmt_keys(value)
}
)
for key, value in self.config.query.items()
}
return {}
def _body(self) -> t.Dict[str, t.Union[t.Dict[str, t.Any], str]]:
data = {
self.config._attribute_map.query_target: self.query_data.query_target,
self.config._attribute_map.query_location: self.query_data.query_location,
self.config._attribute_map.query_type: self.query_data.query_type,
}
if self.config.body_format == "json":
return {"json": data}
elif self.config.body_format == "yaml":
# Third Party
import yaml
return {"content": yaml.dump(data), "headers": {"content-type": "text/yaml"}}
elif self.config.body_format == "xml":
# Third Party
import xmltodict # type: ignore
return {
"content": xmltodict.unparse({"query": data}),
"headers": {"content-type": "application/xml"},
}
elif self.config.body_format == "text":
return {"data": data}
return {}
async def collect(self, *args: t.Any, **kwargs: t.Any) -> t.Iterable:
"""Collect response data from an HTTP endpoint."""
query = self._query_params()
responses = ()
async with self.client as client:
body = {}
if self.config.method in ("POST", "PATCH", "PUT"):
body = self._body()
try:
response: httpx.Response = await client.request(
method=self.config.method, url=self.config.path, params=query, **body
)
response.raise_for_status()
data = response.text.strip()
if len(data) == 0:
raise ResponseEmpty(query=self.query_data)
responses += (data,)
except (httpx.TimeoutException) as error:
raise DeviceTimeout(error=error, device=self.device)
except (httpx.HTTPStatusError) as error:
if error.response.status_code == 401:
raise AuthError(error=error, device=self.device)
raise RestError(error=error, device=self.device)
return responses

View File

@@ -3,7 +3,7 @@
Accepts input from front end application, validates the input and
returns errors if input is invalid. Passes validated parameters to
construct.py, which is used to build & run the Netmiko connections or
hyperglass-frr API calls, returns the output back to the front end.
http client API calls, returns the output back to the front end.
"""
# Standard Library
@@ -22,14 +22,14 @@ if TYPE_CHECKING:
from hyperglass.models.data import OutputDataModel
# Local
from .drivers import AgentConnection, NetmikoConnection
from .drivers import HttpClient, NetmikoConnection
def map_driver(driver_name: str) -> "Connection":
"""Get the correct driver class based on the driver name."""
if driver_name == "hyperglass_agent":
return AgentConnection
if driver_name == "hyperglass_http_client":
return HttpClient
return NetmikoConnection

View File

@@ -19,13 +19,13 @@ from hyperglass.constants import DRIVER_MAP, SCRAPE_HELPERS, SUPPORTED_STRUCTURE
from hyperglass.exceptions.private import ConfigError, UnsupportedDevice
# Local
from .ssl import Ssl
from ..main import MultiModel, HyperglassModel, HyperglassModelWithId
from ..util import check_legacy_fields
from .proxy import Proxy
from ..fields import SupportedDriver
from ..directive import Directives
from .credential import Credential
from .http_client import HttpConfiguration
ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()}
@@ -49,7 +49,7 @@ class Device(HyperglassModelWithId, extra="allow"):
proxy: Optional[Proxy]
display_name: Optional[StrictStr]
port: StrictInt = 22
ssl: Optional[Ssl]
http: HttpConfiguration = HttpConfiguration()
platform: StrictStr
structured_output: Optional[StrictBool]
directives: Directives = Directives()
@@ -219,19 +219,6 @@ class Device(HyperglassModelWithId, extra="allow"):
value = False
return value
@validator("ssl")
def validate_ssl(cls, value, values):
"""Set default cert file location if undefined."""
if value is not None:
if value.enable and value.cert is None:
cert_file = Settings.app_path / "certs" / f'{values["name"]}.pem'
if not cert_file.exists():
log.warning("No certificate found for device {d}", d=values["name"])
cert_file.touch()
value.cert = cert_file
return value
@validator("directives", pre=True, always=True)
def validate_directives(cls: "Device", value, values) -> "Directives":
"""Associate directive IDs to loaded directive objects."""

View File

@@ -0,0 +1,138 @@
"""Configuration models for hyperglass http client."""
# Standard Library
import typing as t
# Third Party
import httpx
from pydantic import (
FilePath,
SecretStr,
StrictInt,
StrictStr,
StrictBool,
PrivateAttr,
IPvAnyAddress,
)
# Project
from hyperglass.models import HyperglassModel
from hyperglass.constants import __version__
# Local
from ..fields import IntFloat, HttpMethod, Primitives
if t.TYPE_CHECKING:
# Local
from .devices import Device
DEFAULT_QUERY_PARAMETERS: t.Dict[str, str] = {
"query_target": "{query_target}",
"query_type": "{query_type}",
"query_location": "{query_location}",
}
BodyFormat = t.Literal["json", "yaml", "xml", "text"]
Scheme = t.Literal["http", "https"]
class AttributeMapConfig(HyperglassModel):
"""Allow the user to 'rewrite' hyperglass field names to their own values."""
query_target: t.Optional[StrictStr]
query_type: t.Optional[StrictStr]
query_location: t.Optional[StrictStr]
class AttributeMap(HyperglassModel):
"""Merged implementation of attribute map configuration."""
query_target: StrictStr
query_type: StrictStr
query_location: StrictStr
class HttpBasicAuth(HyperglassModel):
"""Configuration model for HTTP basic authentication."""
username: StrictStr
password: SecretStr
class HttpConfiguration(HyperglassModel):
"""HTTP client configuration."""
_attribute_map: AttributeMap = PrivateAttr()
path: StrictStr = "/"
method: HttpMethod = "GET"
scheme: Scheme = "https"
query: t.Optional[t.Union[t.Literal[False], t.Dict[str, Primitives]]]
verify_ssl: StrictBool = True
ssl_ca: t.Optional[FilePath]
ssl_client: t.Optional[FilePath]
source: t.Optional[IPvAnyAddress]
timeout: IntFloat = 5
headers: t.Dict[str, str] = {}
follow_redirects: StrictBool = False
basic_auth: t.Optional[HttpBasicAuth]
attribute_map: AttributeMapConfig = AttributeMapConfig()
body_format: BodyFormat = "json"
retries: StrictInt = 0
def __init__(self, **data: t.Any) -> None:
"""Create HTTP Client Configuration Definition."""
super().__init__(**data)
self._attribute_map = self._create_attribute_map()
def _create_attribute_map(self) -> AttributeMap:
"""Create AttributeMap instance with defined overrides."""
return AttributeMap(
query_location=self.attribute_map.query_location or "query_location",
query_type=self.attribute_map.query_type or "query_type",
query_target=self.attribute_map.query_target or "query_target",
)
def create_client(self, *, device: "Device") -> httpx.AsyncClient:
"""Create a pre-configured http client."""
# Use the CA certificates for SSL verification, if present.
verify = self.verify_ssl
if self.ssl_ca is not None:
verify = httpx.create_ssl_context(verify=str(self.ssl_ca))
transport_constructor = {"retries": self.retries}
# Use `source` IP address as httpx transport's `local_address`, if defined.
if self.source is not None:
transport_constructor["local_address"] = str(self.source)
transport = httpx.AsyncHTTPTransport(**transport_constructor)
# Add the port to the URL only if it is not 22, 80, or 443.
base_url = f"{self.scheme}://{device.address!s}".strip("/")
if device.port not in (22, 80, 443):
base_url += f":{device.port!s}"
parameters = {
"verify": verify,
"transport": transport,
"timeout": self.timeout,
"follow_redirects": self.follow_redirects,
"base_url": f"{self.scheme}://{device.address!s}".strip("/"),
"headers": {"user-agent": f"hyperglass/{__version__}", **self.headers},
}
# Use client certificate authentication, if defined.
if self.ssl_client is not None:
parameters["cert"] = str(self.ssl_client)
# Use basic authentication, if defined.
if self.basic_auth is not None:
parameters["auth"] = httpx.BasicAuth(
username=self.basic_auth.username,
password=self.basic_auth.password.get_secret_value(),
)
return httpx.AsyncClient(**parameters)

View File

@@ -1,33 +0,0 @@
"""Validate SSL configuration variables."""
# Standard Library
from typing import Optional
# Third Party
from pydantic import Field, FilePath, StrictBool
# Local
from ..main import HyperglassModel
class Ssl(HyperglassModel):
"""Validate SSL config parameters."""
enable: StrictBool = Field(
True,
title="Enable SSL",
description="If enabled, hyperglass will use HTTPS to connect to the configured device running [hyperglass-agent](/fixme). If enabled, a certificate file must be specified (hyperglass does not support connecting to a device over an unverified SSL session.)",
)
cert: Optional[FilePath]
class Config:
"""Pydantic model configuration."""
title = "SSL"
description = "SSL configuration for devices running hyperglass-agent."
fields = {
"cert": {
"title": "Certificate",
"description": "Valid path to an SSL certificate. This certificate must be the public key used to serve the hyperglass-agent API on the device running hyperglass-agent.",
}
}

View File

@@ -13,6 +13,7 @@ SupportedDriver = t.Literal["netmiko", "hyperglass_agent"]
HttpAuthMode = t.Literal["basic", "api_key"]
HttpProvider = t.Literal["msteams", "slack", "generic"]
LogFormat = t.Literal["text", "json"]
Primitives = t.Union[None, float, int, bool, str]
class AnyUri(str):
@@ -71,3 +72,40 @@ class Action(str):
def __repr__(self):
"""Stringify custom field representation."""
return f"Action({super().__repr__()})"
class HttpMethod(str):
"""Custom field type for HTTP methods."""
methods = (
"CONNECT",
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"TRACE",
)
@classmethod
def __get_validators__(cls):
"""Pydantic custom field method."""
yield cls.validate
@classmethod
def validate(cls, value: str):
"""Ensure http method is valid."""
if not isinstance(value, str):
raise TypeError("HTTP Method must be a string")
value = value.strip().upper()
if value in cls.methods:
return cls(value)
raise ValueError("HTTP Method must be one of {!r}".format(", ".join(cls.methods)))
def __repr__(self):
"""Stringify custom field representation."""
return f"HttpMethod({super().__repr__()})"

118
poetry.lock generated
View File

@@ -6,6 +6,23 @@ category = "main"
optional = false
python-versions = ">=3.6,<4.0"
[[package]]
name = "anyio"
version = "3.3.4"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16)"]
[[package]]
name = "appdirs"
version = "1.4.4"
@@ -25,26 +42,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
pyyaml = "*"
[[package]]
name = "asyncssh"
version = "2.7.0"
description = "AsyncSSH: Asynchronous SSHv2 client and server library"
category = "main"
optional = false
python-versions = ">= 3.6"
[package.dependencies]
cryptography = ">=2.8"
[package.extras]
bcrypt = ["bcrypt (>=3.1.3)"]
fido2 = ["fido2 (==0.9.1)"]
gssapi = ["gssapi (>=1.2.0)"]
libnacl = ["libnacl (>=1.4.2)"]
pkcs11 = ["python-pkcs11 (>=0.7.0)"]
pyOpenSSL = ["pyOpenSSL (>=17.0.0)"]
pywin32 = ["pywin32 (>=227)"]
[[package]]
name = "atomicwrites"
version = "1.4.0"
@@ -145,6 +142,17 @@ category = "dev"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "charset-normalizer"
version = "2.0.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "7.1.2"
@@ -508,22 +516,23 @@ tornado = ["tornado (>=0.2)"]
[[package]]
name = "h11"
version = "0.9.0"
version = "0.12.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = "*"
python-versions = ">=3.6"
[[package]]
name = "httpcore"
version = "0.12.3"
version = "0.13.7"
description = "A minimal low-level HTTP client."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
h11 = "<1.0.0"
anyio = ">=3.0.0,<4.0.0"
h11 = ">=0.11,<0.13"
sniffio = ">=1.0.0,<2.0.0"
[package.extras]
@@ -542,7 +551,7 @@ test = ["Cython (==0.29.14)"]
[[package]]
name = "httpx"
version = "0.17.1"
version = "0.20.0"
description = "The next generation HTTP client."
category = "main"
optional = false
@@ -550,13 +559,15 @@ python-versions = ">=3.6"
[package.dependencies]
certifi = "*"
httpcore = ">=0.12.1,<0.13"
charset-normalizer = "*"
httpcore = ">=0.13.3,<0.14.0"
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
sniffio = "*"
[package.extras]
brotli = ["brotlipy (>=0.7.0,<0.8.0)"]
http2 = ["h2 (>=3.0.0,<4.0.0)"]
brotli = ["brotlicffi", "brotli"]
cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"]
http2 = ["h2 (>=3,<5)"]
[[package]]
name = "identify"
@@ -1032,27 +1043,6 @@ python-versions = "*"
[package.dependencies]
paramiko = "*"
[[package]]
name = "scrapli"
version = "2021.7.30"
description = "Fast, flexible, sync/async, Python 3.6+ screen scraping client specifically for network devices"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
asyncssh = {version = ">=2.2.1,<3.0.0", optional = true, markers = "extra == \"asyncssh\""}
[package.extras]
asyncssh = ["asyncssh (>=2.2.1,<3.0.0)"]
community = ["scrapli-community (>=2021.01.30)"]
full = ["ntc-templates (>=1.1.0,<3.0.0)", "textfsm (>=1.1.0,<2.0.0)", "ttp (>=0.5.0,<1.0.0)", "paramiko (>=2.6.0,<3.0.0)", "asyncssh (>=2.2.1,<3.0.0)", "scrapli-community (>=2021.01.30)", "ssh2-python (>=0.23.0,<1.0.0)", "genie (>=20.2)", "pyats (>=20.2)"]
genie = ["genie (>=20.2)", "pyats (>=20.2)"]
paramiko = ["paramiko (>=2.6.0,<3.0.0)"]
ssh2 = ["ssh2-python (>=0.23.0,<1.0.0)"]
textfsm = ["ntc-templates (>=1.1.0,<3.0.0)", "textfsm (>=1.1.0,<2.0.0)"]
ttp = ["ttp (>=0.5.0,<1.0.0)"]
[[package]]
name = "six"
version = "1.15.0"
@@ -1332,13 +1322,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[metadata]
lock-version = "1.1"
python-versions = ">=3.8.1,<4.0"
content-hash = "d4a0600f54f56ba3641943af10908d325a459f301c073ec0f2f354ca7869d0ed"
content-hash = "ba2c36614f210b1e9a0fe576a7854bef0e03f678da7b7d5eba724cb794c7baf1"
[metadata.files]
aiofiles = [
{file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
{file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
]
anyio = [
{file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"},
{file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"},
]
appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
@@ -1347,10 +1341,6 @@ appdirs = [
{file = "aspy.yaml-1.3.0-py2.py3-none-any.whl", hash = "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc"},
{file = "aspy.yaml-1.3.0.tar.gz", hash = "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"},
]
asyncssh = [
{file = "asyncssh-2.7.0-py3-none-any.whl", hash = "sha256:ccc62a1b311c71d4bf8e4bc3ac141eb00ebb28b324e375aed1d0a03232893ca1"},
{file = "asyncssh-2.7.0.tar.gz", hash = "sha256:185013d8e67747c3c0f01b72416b8bd78417da1df48c71f76da53c607ef541b6"},
]
atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
@@ -1422,6 +1412,10 @@ cfgv = [
{file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
{file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
]
charset-normalizer = [
{file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
{file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
]
click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
@@ -1559,12 +1553,12 @@ gunicorn = [
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
]
h11 = [
{file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"},
{file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"},
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
]
httpcore = [
{file = "httpcore-0.12.3-py3-none-any.whl", hash = "sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"},
{file = "httpcore-0.12.3.tar.gz", hash = "sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9"},
{file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"},
{file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"},
]
httptools = [
{file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"},
@@ -1581,8 +1575,8 @@ httptools = [
{file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"},
]
httpx = [
{file = "httpx-0.17.1-py3-none-any.whl", hash = "sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272"},
{file = "httpx-0.17.1.tar.gz", hash = "sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967"},
{file = "httpx-0.20.0-py3-none-any.whl", hash = "sha256:33af5aad9bdc82ef1fc89219c1e36f5693bf9cd0ebe330884df563445682c0f8"},
{file = "httpx-0.20.0.tar.gz", hash = "sha256:09606d630f070d07f9ff28104fbcea429ea0014c1e89ac90b4d8de8286c40e7b"},
]
identify = [
{file = "identify-1.5.5-py2.py3-none-any.whl", hash = "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"},
@@ -1942,10 +1936,6 @@ scp = [
{file = "scp-0.13.3-py2.py3-none-any.whl", hash = "sha256:f2fa9fb269ead0f09b4e2ceb47621beb7000c135f272f6b70d3d9d29928d7bf0"},
{file = "scp-0.13.3.tar.gz", hash = "sha256:8bd748293d7362073169b96ce4b8c4f93bcc62cfc5f7e1d949e01e406a025bd4"},
]
scrapli = [
{file = "scrapli-2021.7.30-py3-none-any.whl", hash = "sha256:7bdf482a79d0a3d24a9a776b8d82686bc201a4c828fd14a917453177c0008d98"},
{file = "scrapli-2021.7.30.tar.gz", hash = "sha256:fa1e27a7f6281e6ea8ae8bb096b637b2f5b0ecf37251160b839577a1c0cef40f"},
]
six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},

View File

@@ -38,7 +38,7 @@ distro = "^1.5.0"
fastapi = "^0.63.0"
favicons = ">=0.1.0,<1.0"
gunicorn = "^20.1.0"
httpx = "^0.17.1"
httpx = "^0.20.0"
loguru = "^0.5.3"
netmiko = "^3.4.0"
paramiko = "^2.7.2"