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:
		@@ -80,4 +80,5 @@ DRIVER_MAP = {
 | 
			
		||||
    "bird_legacy": "hyperglass_agent",
 | 
			
		||||
    "bird": "netmiko",
 | 
			
		||||
    "frr": "netmiko",
 | 
			
		||||
    "http": "hyperglass_http_client",
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
							
								
								
									
										122
									
								
								hyperglass/execution/drivers/http_client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								hyperglass/execution/drivers/http_client.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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."""
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										138
									
								
								hyperglass/models/config/http_client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								hyperglass/models/config/http_client.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
@@ -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.",
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
@@ -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
									
									
									
								
							
							
						
						
									
										118
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							@@ -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"},
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user