diff --git a/hyperglass/constants.py b/hyperglass/constants.py index e903d74..72576d9 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -80,4 +80,5 @@ DRIVER_MAP = { "bird_legacy": "hyperglass_agent", "bird": "netmiko", "frr": "netmiko", + "http": "hyperglass_http_client", } diff --git a/hyperglass/execution/drivers/__init__.py b/hyperglass/execution/drivers/__init__.py index 653df2b..1ebbab4 100644 --- a/hyperglass/execution/drivers/__init__.py +++ b/hyperglass/execution/drivers/__init__.py @@ -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", ) diff --git a/hyperglass/execution/drivers/agent.py b/hyperglass/execution/drivers/agent.py deleted file mode 100644 index 92bd573..0000000 --- a/hyperglass/execution/drivers/agent.py +++ /dev/null @@ -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 diff --git a/hyperglass/execution/drivers/http_client.py b/hyperglass/execution/drivers/http_client.py new file mode 100644 index 0000000..79072a8 --- /dev/null +++ b/hyperglass/execution/drivers/http_client.py @@ -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 diff --git a/hyperglass/execution/main.py b/hyperglass/execution/main.py index ecc5297..51309db 100644 --- a/hyperglass/execution/main.py +++ b/hyperglass/execution/main.py @@ -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 diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index 11f1d88..819fcaf 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -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.""" diff --git a/hyperglass/models/config/http_client.py b/hyperglass/models/config/http_client.py new file mode 100644 index 0000000..1690291 --- /dev/null +++ b/hyperglass/models/config/http_client.py @@ -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) diff --git a/hyperglass/models/config/ssl.py b/hyperglass/models/config/ssl.py deleted file mode 100644 index e9dc32e..0000000 --- a/hyperglass/models/config/ssl.py +++ /dev/null @@ -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.", - } - } diff --git a/hyperglass/models/fields.py b/hyperglass/models/fields.py index 5782958..da37e8f 100644 --- a/hyperglass/models/fields.py +++ b/hyperglass/models/fields.py @@ -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__()})" diff --git a/poetry.lock b/poetry.lock index afafe8b..a5d7c7c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index ff781ce..37706ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"