From bda26c64aaf66abd35ddccb32497121e09de6395 Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Wed, 16 Aug 2023 16:23:00 +1000 Subject: [PATCH 1/7] Add threaded client --- apitally/client.py | 134 ----- apitally/client/__init__.py | 0 apitally/client/asyncio.py | 83 ++++ apitally/client/base.py | 181 +++++++ apitally/client/threading.py | 103 ++++ apitally/fastapi.py | 4 +- apitally/flask.py | 0 apitally/keys.py | 62 --- apitally/requests.py | 43 -- apitally/starlette.py | 13 +- poetry.lock | 459 +++++++++++++++++- pyproject.toml | 26 +- tests/conftest.py | 11 + ...{test_client.py => test_client_asyncio.py} | 37 +- tests/{test_keys.py => test_client_base.py} | 33 +- tests/test_client_threading.py | 91 ++++ tests/test_fastapi.py | 4 +- tests/test_requests.py | 43 -- tests/test_starlette.py | 33 +- 19 files changed, 1014 insertions(+), 346 deletions(-) delete mode 100644 apitally/client.py create mode 100644 apitally/client/__init__.py create mode 100644 apitally/client/asyncio.py create mode 100644 apitally/client/base.py create mode 100644 apitally/client/threading.py create mode 100644 apitally/flask.py delete mode 100644 apitally/keys.py delete mode 100644 apitally/requests.py rename tests/{test_client.py => test_client_asyncio.py} (64%) rename tests/{test_keys.py => test_client_base.py} (62%) create mode 100644 tests/test_client_threading.py delete mode 100644 tests/test_requests.py diff --git a/apitally/client.py b/apitally/client.py deleted file mode 100644 index 0c51014..0000000 --- a/apitally/client.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import os -import threading -from typing import Any, Dict, Optional -from uuid import uuid4 - -import backoff -import httpx - -from apitally.keys import KeyRegistry -from apitally.requests import RequestLogger - - -logger = logging.getLogger(__name__) - -HUB_BASE_URL = os.getenv("APITALLY_HUB_BASE_URL") or "https://hub.apitally.io" -HUB_VERSION = "v1" - - -def handle_retry_giveup(details) -> None: - logger.error("Apitally client failed to sync with hub: {target.__name__}: {exception}".format(**details)) - - -retry = backoff.on_exception( - backoff.expo, httpx.HTTPError, max_tries=3, on_giveup=handle_retry_giveup, raise_on_giveup=False -) - - -class ApitallyClient: - _instance: Optional[ApitallyClient] = None - _lock = threading.Lock() - - def __new__(cls, *args, **kwargs) -> ApitallyClient: - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self, client_id: str, env: str, enable_keys: bool = False, send_every: float = 60) -> None: - self.enable_keys = enable_keys - self.send_every = send_every - - if hasattr(self, "client_id") and hasattr(self, "env"): - if getattr(self, "client_id") != client_id or getattr(self, "env") != env: - raise RuntimeError("Apitally client is already initialized with different client_id or env") - return - - self.client_id = client_id - self.env = env - self.instance_uuid = str(uuid4()) - self.request_logger = RequestLogger() - self.key_registry = KeyRegistry() - self._stop_sync_loop = False - self.start_sync_loop() - - @classmethod - def get_instance(cls) -> ApitallyClient: - if cls._instance is None: - raise RuntimeError("Apitally client not initialized") - return cls._instance - - def get_http_client(self) -> httpx.AsyncClient: - base_url = f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}" - return httpx.AsyncClient(base_url=base_url) - - def start_sync_loop(self) -> None: - self._stop_sync_loop = False - if self.enable_keys: - asyncio.create_task(self.get_keys()) - asyncio.create_task(self._run_sync_loop()) - - async def _run_sync_loop(self) -> None: - while not self._stop_sync_loop: - try: - await asyncio.sleep(self.send_every) - async with self.get_http_client() as client: - await self.send_requests_data(client) - if self.enable_keys: - await self._get_keys(client) - except Exception as e: - logger.exception(e) - - def stop_sync_loop(self) -> None: - self._stop_sync_loop = True - - def send_app_info(self, app_info: Dict[str, Any]) -> None: - payload = { - "instance_uuid": self.instance_uuid, - "message_uuid": str(uuid4()), - } - payload.update(app_info) - asyncio.create_task(self._send_app_info(payload=payload)) - - @retry - async def _send_app_info(self, payload: Any) -> None: - async with self.get_http_client() as client: - response = await client.post(url="/info", json=payload) - if response.status_code == 404 and "Client ID" in response.text: - self.stop_sync_loop() - logger.error(f"Invalid Apitally client ID {self.client_id}") - elif response.status_code >= 400: - response.raise_for_status() - - async def send_requests_data(self, client: httpx.AsyncClient) -> None: - requests = self.request_logger.get_and_reset_requests() - used_key_ids = self.key_registry.get_and_reset_used_key_ids() if self.enable_keys else [] - payload: Dict[str, Any] = { - "instance_uuid": self.instance_uuid, - "message_uuid": str(uuid4()), - "requests": requests, - "used_key_ids": used_key_ids, - } - await self._send_requests_data(client, payload) - - @retry - async def _send_requests_data(self, client: httpx.AsyncClient, payload: Any) -> None: - response = await client.post(url="/requests", json=payload) - response.raise_for_status() - - async def get_keys(self) -> None: - async with self.get_http_client() as client: - await self._get_keys(client) - - @retry - async def _get_keys(self, client: httpx.AsyncClient) -> None: - response = await client.get(url="/keys") - response.raise_for_status() - response_data = response.json() - self.key_registry.salt = response_data["salt"] - self.key_registry.update(response_data["keys"]) diff --git a/apitally/client/__init__.py b/apitally/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apitally/client/asyncio.py b/apitally/client/asyncio.py new file mode 100644 index 0000000..7eadafb --- /dev/null +++ b/apitally/client/asyncio.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict + +import backoff +import httpx + +from apitally.client.base import ApitallyClientBase, handle_retry_giveup + + +logger = logging.getLogger(__name__) +retry = backoff.on_exception( + backoff.expo, + httpx.HTTPError, + max_tries=3, + on_giveup=handle_retry_giveup, + raise_on_giveup=False, +) + + +class ApitallyClient(ApitallyClientBase): + def __init__(self, client_id: str, env: str, enable_keys: bool = False, sync_interval: float = 60) -> None: + super().__init__(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) + self._stop_sync_loop = False + + def get_http_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(base_url=self.hub_url) + + def start_sync_loop(self) -> None: + self._stop_sync_loop = False + asyncio.create_task(self._run_sync_loop()) + + async def _run_sync_loop(self) -> None: + if self.enable_keys: + async with self.get_http_client() as client: + await self.get_keys(client) + while not self._stop_sync_loop: + try: + await asyncio.sleep(self.sync_interval) + async with self.get_http_client() as client: + await self.send_requests_data(client) + if self.enable_keys: + await self.get_keys(client) + except Exception as e: # pragma: no cover + logger.exception(e) + + def stop_sync_loop(self) -> None: + self._stop_sync_loop = True + + def send_app_info(self, app_info: Dict[str, Any]) -> None: + payload = self.get_info_payload(app_info) + asyncio.create_task(self._send_app_info(payload=payload)) + + async def send_requests_data(self, client: httpx.AsyncClient) -> None: + payload = self.get_requests_payload() + await self._send_requests_data(client, payload) + + async def get_keys(self, client: httpx.AsyncClient) -> None: + response_data = await self._get_keys(client) + self.handle_keys_response(response_data) + + @retry + async def _send_app_info(self, payload: Dict[str, Any]) -> None: + async with self.get_http_client() as client: + response = await client.post(url="/info", json=payload) + if response.status_code == 404 and "Client ID" in response.text: + self.stop_sync_loop() + logger.error(f"Invalid Apitally client ID {self.client_id}") + else: + response.raise_for_status() + + @retry + async def _send_requests_data(self, client: httpx.AsyncClient, payload: Dict[str, Any]) -> None: + response = await client.post(url="/requests", json=payload) + response.raise_for_status() + + @retry + async def _get_keys(self, client: httpx.AsyncClient) -> Dict[str, Any]: + response = await client.get(url="/keys") + response.raise_for_status() + return response.json() diff --git a/apitally/client/base.py b/apitally/client/base.py new file mode 100644 index 0000000..fd7feb7 --- /dev/null +++ b/apitally/client/base.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import logging +import os +import threading +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from hashlib import scrypt +from math import floor +from typing import Any, Dict, List, Optional, Set, Type, TypeVar, cast +from uuid import uuid4 + + +logger = logging.getLogger(__name__) + +HUB_BASE_URL = os.getenv("APITALLY_HUB_BASE_URL") or "https://hub.apitally.io" +HUB_VERSION = "v1" + +TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase") + + +def handle_retry_giveup(details) -> None: # pragma: no cover + logger.error("Apitally client failed to sync with hub: {target.__name__}: {exception}".format(**details)) + + +class ApitallyClientBase: + _instance: Optional[ApitallyClientBase] = None + _lock = threading.Lock() + + def __new__(cls, *args, **kwargs) -> ApitallyClientBase: + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, client_id: str, env: str, enable_keys: bool = False, sync_interval: float = 60) -> None: + if hasattr(self, "client_id"): + raise RuntimeError("Apitally client is already initialized") # pragma: no cover + + self.client_id = client_id + self.env = env + self.enable_keys = enable_keys + self.sync_interval = sync_interval + self.instance_uuid = str(uuid4()) + self.request_logger = RequestLogger() + self.key_registry = KeyRegistry() + + @classmethod + def get_instance(cls: Type[TApitallyClient]) -> TApitallyClient: + if cls._instance is None: + raise RuntimeError("Apitally client not initialized") # pragma: no cover + return cast(TApitallyClient, cls._instance) + + @property + def hub_url(self) -> str: + return f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}" + + def send_app_info(self, app_info: Dict[str, Any]) -> None: + raise NotImplementedError # pragma: no cover + + def get_info_payload(self, app_info: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "instance_uuid": self.instance_uuid, + "message_uuid": str(uuid4()), + } + payload.update(app_info) + return payload + + def get_requests_payload(self) -> Dict[str, Any]: + requests = self.request_logger.get_and_reset_requests() + used_key_ids = self.key_registry.get_and_reset_used_key_ids() if self.enable_keys else [] + return { + "instance_uuid": self.instance_uuid, + "message_uuid": str(uuid4()), + "requests": requests, + "used_key_ids": used_key_ids, + } + + def handle_keys_response(self, response_data: Dict[str, Any]) -> None: + self.key_registry.salt = response_data["salt"] + self.key_registry.update(response_data["keys"]) + + +@dataclass(frozen=True) +class RequestInfo: + method: str + path: str + status_code: int + + +class RequestLogger: + def __init__(self) -> None: + self.request_counts: Counter[RequestInfo] = Counter() + self.response_times: Dict[RequestInfo, Counter[int]] = {} + self._lock = threading.Lock() + + def log_request(self, method: str, path: str, status_code: int, response_time: float) -> None: + request_info = RequestInfo(method=method, path=path, status_code=status_code) + response_time_ms_bin = int(floor(response_time / 0.01) * 10) # In ms, rounded down to nearest 10ms + with self._lock: + self.request_counts[request_info] += 1 + self.response_times.setdefault(request_info, Counter())[response_time_ms_bin] += 1 + + def get_and_reset_requests(self) -> List[Dict[str, Any]]: + data: List[Dict[str, Any]] = [] + with self._lock: + for request_info, count in self.request_counts.items(): + data.append( + { + "method": request_info.method, + "path": request_info.path, + "status_code": request_info.status_code, + "request_count": count, + "response_times": self.response_times.get(request_info) or Counter(), + } + ) + self.request_counts.clear() + self.response_times.clear() + return data + + +@dataclass(frozen=True) +class KeyInfo: + key_id: int + name: str = "" + scopes: List[str] = field(default_factory=list) + expires_at: Optional[datetime] = None + + @property + def is_expired(self) -> bool: + return self.expires_at is not None and self.expires_at < datetime.now() + + def check_scopes(self, scopes: List[str]) -> bool: + return all(scope in self.scopes for scope in scopes) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> KeyInfo: + return cls( + key_id=data["key_id"], + name=data.get("name", ""), + scopes=data.get("scopes", []), + expires_at=( + datetime.now() + timedelta(seconds=data["expires_in_seconds"]) + if data["expires_in_seconds"] is not None + else None + ), + ) + + +class KeyRegistry: + def __init__(self) -> None: + self.salt: Optional[str] = None + self.keys: Dict[str, KeyInfo] = {} + self.used_key_ids: Set[int] = set() + self._lock = threading.Lock() + + def get(self, api_key: str) -> Optional[KeyInfo]: + hash = self.hash_api_key(api_key) + with self._lock: + key = self.keys.get(hash) + if key is None or key.is_expired: + return None + self.used_key_ids.add(key.key_id) + return key + + def hash_api_key(self, api_key: str) -> str: + if self.salt is None: + raise RuntimeError("Apitally keys not initialized") + return scrypt(api_key.encode(), salt=bytes.fromhex(self.salt), n=256, r=4, p=1, dklen=32).hex() + + def update(self, keys: Dict[str, Dict[str, Any]]) -> None: + with self._lock: + self.keys = {hash: KeyInfo.from_dict(data) for hash, data in keys.items()} + + def get_and_reset_used_key_ids(self) -> List[int]: + with self._lock: + data = list(self.used_key_ids) + self.used_key_ids.clear() + return data diff --git a/apitally/client/threading.py b/apitally/client/threading.py new file mode 100644 index 0000000..acd2fc9 --- /dev/null +++ b/apitally/client/threading.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging +import time +from threading import Event, Thread +from typing import Any, Callable, Dict, Optional + +import backoff +import requests + +from apitally.client.base import ApitallyClientBase, handle_retry_giveup + + +logger = logging.getLogger(__name__) +retry = backoff.on_exception( + backoff.expo, + requests.HTTPError, + max_tries=3, + on_giveup=handle_retry_giveup, + raise_on_giveup=False, +) + + +# Function to register an on-exit callback for both Python and IPython runtimes +try: + + def register_exit(func: Callable[..., Any], *args, **kwargs) -> Callable[..., Any]: # pragma: no cover + def callback(): + func() + ipython.events.unregister("post_execute", callback) + + ipython.events.register("post_execute", callback) + return func + + ipython = get_ipython() # type: ignore +except NameError: + from atexit import register as register_exit + + +class ApitallyClient(ApitallyClientBase): + def __init__(self, client_id: str, env: str, enable_keys: bool = False, sync_interval: float = 60) -> None: + super().__init__(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) + self._thread: Optional[Thread] = None + self._stop_sync_loop = Event() + + def start_sync_loop(self) -> None: + self._stop_sync_loop.clear() + if self._thread is None or not self._thread.is_alive(): + self._thread = Thread(target=self._run_sync_loop) + self._thread.start() + register_exit(self.stop_sync_loop) + + def _run_sync_loop(self) -> None: + if self.enable_keys: + with requests.Session() as session: + self.get_keys(session) + while not self._stop_sync_loop.is_set(): + try: + time.sleep(self.sync_interval) + with requests.Session() as session: + self.send_requests_data(session) + if self.enable_keys: + self.get_keys(session) + except Exception as e: # pragma: no cover + logger.exception(e) + + def stop_sync_loop(self) -> None: + self._stop_sync_loop.set() + if self._thread is not None: + self._thread.join() + self._thread = None + + def send_app_info(self, app_info: Dict[str, Any]) -> None: + payload = self.get_info_payload(app_info) + self._send_app_info(payload=payload) + + def send_requests_data(self, session: requests.Session) -> None: + payload = self.get_requests_payload() + self._send_requests_data(session, payload) + + def get_keys(self, session: requests.Session) -> None: + response_data = self._get_keys(session) + self.handle_keys_response(response_data) + + @retry + def _send_app_info(self, payload: Dict[str, Any]) -> None: + response = requests.post(url=f"{self.hub_url}/info", json=payload) + if response.status_code == 404 and "Client ID" in response.text: + self.stop_sync_loop() + logger.error(f"Invalid Apitally client ID {self.client_id}") + else: + response.raise_for_status() + + @retry + def _send_requests_data(self, session: requests.Session, payload: Dict[str, Any]) -> None: + response = session.post(url=f"{self.hub_url}/requests", json=payload) + response.raise_for_status() + + @retry + def _get_keys(self, session: requests.Session) -> Dict[str, Any]: + response = session.get(url=f"{self.hub_url}/keys") + response.raise_for_status() + return response.json() diff --git a/apitally/fastapi.py b/apitally/fastapi.py index a7e296f..a32a6aa 100644 --- a/apitally/fastapi.py +++ b/apitally/fastapi.py @@ -8,8 +8,8 @@ from fastapi.security.utils import get_authorization_scheme_param from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN -from apitally.client import ApitallyClient -from apitally.keys import KeyInfo +from apitally.client.asyncio import ApitallyClient +from apitally.client.base import KeyInfo from apitally.starlette import ApitallyMiddleware diff --git a/apitally/flask.py b/apitally/flask.py new file mode 100644 index 0000000..e69de29 diff --git a/apitally/keys.py b/apitally/keys.py deleted file mode 100644 index 6d45983..0000000 --- a/apitally/keys.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from hashlib import scrypt -from typing import Any, Dict, List, Optional, Set - - -@dataclass(frozen=True) -class KeyInfo: - key_id: int - name: str = "" - scopes: List[str] = field(default_factory=list) - expires_at: Optional[datetime] = None - - @property - def is_expired(self) -> bool: - return self.expires_at is not None and self.expires_at < datetime.now() - - def check_scopes(self, scopes: List[str]) -> bool: - return all(scope in self.scopes for scope in scopes) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> KeyInfo: - return cls( - key_id=data["key_id"], - name=data.get("name", ""), - scopes=data.get("scopes", []), - expires_at=( - datetime.now() + timedelta(seconds=data["expires_in_seconds"]) - if data["expires_in_seconds"] is not None - else None - ), - ) - - -class KeyRegistry: - def __init__(self) -> None: - self.salt: Optional[str] = None - self.keys: Dict[str, KeyInfo] = {} - self.used_key_ids: Set[int] = set() - - def get(self, api_key: str) -> Optional[KeyInfo]: - hash = self.hash_api_key(api_key) - key = self.keys.get(hash) - if key is None or key.is_expired: - return None - self.used_key_ids.add(key.key_id) - return key - - def hash_api_key(self, api_key: str) -> str: - if self.salt is None: - raise RuntimeError("Apitally keys not initialized") - return scrypt(api_key.encode(), salt=bytes.fromhex(self.salt), n=256, r=4, p=1, dklen=32).hex() - - def update(self, keys: Dict[str, Dict[str, Any]]) -> None: - self.keys = {hash: KeyInfo.from_dict(data) for hash, data in keys.items()} - - def get_and_reset_used_key_ids(self) -> List[int]: - data = list(self.used_key_ids) - self.used_key_ids.clear() - return data diff --git a/apitally/requests.py b/apitally/requests.py deleted file mode 100644 index 5b5823c..0000000 --- a/apitally/requests.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import Counter -from dataclasses import dataclass -from math import floor -from typing import Any, Dict, List - - -@dataclass(frozen=True) -class RequestInfo: - method: str - path: str - status_code: int - - -class RequestLogger: - def __init__(self) -> None: - self.request_count: Counter[RequestInfo] = Counter() - self.response_times: Dict[RequestInfo, Counter[int]] = {} - self.lock = asyncio.Lock() - - def log_request(self, method: str, path: str, status_code: int, response_time: float) -> None: - request_info = RequestInfo(method=method, path=path, status_code=status_code) - response_time_ms_bin = int(floor(response_time / 0.01) * 10) # In ms, rounded down to nearest 10ms - self.request_count[request_info] += 1 - self.response_times.setdefault(request_info, Counter())[response_time_ms_bin] += 1 - - def get_and_reset_requests(self) -> List[Dict[str, Any]]: - data: List[Dict[str, Any]] = [] - for request_info, count in self.request_count.items(): - data.append( - { - "method": request_info.method, - "path": request_info.path, - "status_code": request_info.status_code, - "request_count": count, - "response_times": self.response_times.get(request_info) or Counter(), - } - ) - self.request_count.clear() - self.response_times.clear() - return data diff --git a/apitally/starlette.py b/apitally/starlette.py index 971644d..b3be26b 100644 --- a/apitally/starlette.py +++ b/apitally/starlette.py @@ -24,8 +24,8 @@ from starlette.types import ASGIApp import apitally -from apitally.client import ApitallyClient -from apitally.keys import KeyInfo +from apitally.client.asyncio import ApitallyClient +from apitally.client.base import KeyInfo if TYPE_CHECKING: @@ -45,7 +45,7 @@ def __init__( env: str = "default", app_version: Optional[str] = None, enable_keys: bool = False, - send_every: float = 60, + sync_interval: float = 60, filter_unhandled_paths: bool = True, openapi_url: Optional[str] = "/openapi.json", ) -> None: @@ -57,12 +57,13 @@ def __init__( raise ValueError(f"invalid env '{env}' (expected 1-32 alphanumeric lowercase characters and hyphens only)") if app_version is not None and len(app_version) > 32: raise ValueError(f"invalid app_version '{app_version}' (expected 1-32 characters)") - if send_every < 10: - raise ValueError("send_every has to be greater or equal to 10 seconds") + if sync_interval < 10: + raise ValueError("sync_interval has to be greater or equal to 10 seconds") self.filter_unhandled_paths = filter_unhandled_paths - self.client = ApitallyClient(client_id=client_id, env=env, enable_keys=enable_keys, send_every=send_every) + self.client = ApitallyClient(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) self.client.send_app_info(app_info=_get_app_info(app, app_version, openapi_url)) + self.client.start_sync_loop() super().__init__(app) async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: diff --git a/poetry.lock b/poetry.lock index c99fd1c..f22faaf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4,7 +4,7 @@ name = "annotated-types" version = "0.5.0" description = "Reusable constraint types to use with typing.Annotated" -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"}, @@ -92,6 +92,17 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "blinker" +version = "1.6.2" +description = "Fast, simple object-to-object and broadcast signaling" +optional = true +python-versions = ">=3.7" +files = [ + {file = "blinker-1.6.2-py3-none-any.whl", hash = "sha256:c3d739772abb7bc2860abf5f2ec284223d9ad5c76da018234f6f50d6f31ab1f0"}, + {file = "blinker-1.6.2.tar.gz", hash = "sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213"}, +] + [[package]] name = "certifi" version = "2023.7.22" @@ -114,6 +125,90 @@ files = [ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] +[[package]] +name = "charset-normalizer" +version = "3.2.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, +] + [[package]] name = "click" version = "8.1.6" @@ -235,7 +330,7 @@ test = ["pytest (>=6)"] name = "fastapi" version = "0.101.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "fastapi-0.101.1-py3-none-any.whl", hash = "sha256:aef5f8676eb1b8389952e1fe734abe20f04b71f6936afcc53b320ba79b686a4b"}, @@ -265,6 +360,29 @@ files = [ docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +[[package]] +name = "flask" +version = "2.3.2" +description = "A simple framework for building complex web applications." +optional = true +python-versions = ">=3.8" +files = [ + {file = "Flask-2.3.2-py3-none-any.whl", hash = "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0"}, + {file = "Flask-2.3.2.tar.gz", hash = "sha256:8c2f9abd47a9e8df7f0c3f091ce9497d011dc3b31effcf4c85a6e2b50f4114ef"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=2.3.3" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "h11" version = "0.14.0" @@ -345,6 +463,25 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "importlib-metadata" +version = "6.8.0" +description = "Read metadata from Python packages" +optional = true +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, + {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -356,6 +493,93 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = true +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +optional = true +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = true +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + [[package]] name = "mypy" version = "1.5.0" @@ -496,7 +720,7 @@ virtualenv = ">=20.10.0" name = "pydantic" version = "2.1.1" description = "Data validation using Python type hints" -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "pydantic-2.1.1-py3-none-any.whl", hash = "sha256:43bdbf359d6304c57afda15c2b95797295b702948082d4c23851ce752f21da70"}, @@ -515,7 +739,7 @@ email = ["email-validator (>=2.0.0)"] name = "pydantic-core" version = "2.4.0" description = "" -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "pydantic_core-2.4.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:2ca4687dd996bde7f3c420def450797feeb20dcee2b9687023e3323c73fc14a2"}, @@ -766,6 +990,46 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-mock" +version = "1.11.0" +description = "Mock out responses from the requests package" +optional = false +python-versions = "*" +files = [ + {file = "requests-mock-1.11.0.tar.gz", hash = "sha256:ef10b572b489a5f28e09b708697208c4a3b2b89ef80a9f01584340ea357ec3c4"}, + {file = "requests_mock-1.11.0-py2.py3-none-any.whl", hash = "sha256:f7fae383f228633f6bececebdab236c478ace2284d6292c6e7e2867b9ab74d15"}, +] + +[package.dependencies] +requests = ">=2.3,<3" +six = "*" + +[package.extras] +fixture = ["fixtures"] +test = ["fixtures", "mock", "purl", "pytest", "requests-futures", "sphinx", "testtools"] + [[package]] name = "ruff" version = "0.0.275" @@ -794,20 +1058,31 @@ files = [ [[package]] name = "setuptools" -version = "68.0.0" +version = "68.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, - {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, + {file = "setuptools-68.1.0-py3-none-any.whl", hash = "sha256:e13e1b0bc760e9b0127eda042845999b2f913e12437046e663b833aa96d89715"}, + {file = "setuptools-68.1.0.tar.gz", hash = "sha256:d59c97e7b774979a5ccb96388efc9eb65518004537e85d52e81eaee89ab6dd91"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.0" @@ -823,7 +1098,7 @@ files = [ name = "starlette" version = "0.27.0" description = "The little ASGI library that shines." -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, @@ -848,6 +1123,112 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "types-colorama" +version = "0.4.15.12" +description = "Typing stubs for colorama" +optional = false +python-versions = "*" +files = [ + {file = "types-colorama-0.4.15.12.tar.gz", hash = "sha256:fbdfc5d9d24d85c33bd054fbe33adc6cec44eedb19cfbbabfbbb57dc257ae4b8"}, + {file = "types_colorama-0.4.15.12-py3-none-any.whl", hash = "sha256:23c9d4a00961227f7ef018d5a1c190c4bbc282119c3ee76a17677a793f13bb82"}, +] + +[[package]] +name = "types-docutils" +version = "0.20.0.2" +description = "Typing stubs for docutils" +optional = false +python-versions = "*" +files = [ + {file = "types-docutils-0.20.0.2.tar.gz", hash = "sha256:91ca0d7e3a0ea8b5a590689ed57d3fb93f1439f6e2055f6c567882dab3283b79"}, + {file = "types_docutils-0.20.0.2-py3-none-any.whl", hash = "sha256:bf25a73efb8b8e5d8833732fd7a17847369b42f64defd62cd2c9a632fa248f90"}, +] + +[[package]] +name = "types-pygments" +version = "2.16.0.0" +description = "Typing stubs for Pygments" +optional = false +python-versions = "*" +files = [ + {file = "types-Pygments-2.16.0.0.tar.gz", hash = "sha256:aa93e4664e2d6cfea7570cde156e3966bf939f9c7d736cd179c4c8e94f7600b2"}, + {file = "types_Pygments-2.16.0.0-py3-none-any.whl", hash = "sha256:4624a547d5ba73c971fac5d6fd327141e85e65f6123448bee76f0c8557652a71"}, +] + +[package.dependencies] +types-docutils = "*" +types-setuptools = "*" + +[[package]] +name = "types-pyyaml" +version = "6.0.12.11" +description = "Typing stubs for PyYAML" +optional = false +python-versions = "*" +files = [ + {file = "types-PyYAML-6.0.12.11.tar.gz", hash = "sha256:7d340b19ca28cddfdba438ee638cd4084bde213e501a3978738543e27094775b"}, + {file = "types_PyYAML-6.0.12.11-py3-none-any.whl", hash = "sha256:a461508f3096d1d5810ec5ab95d7eeecb651f3a15b71959999988942063bf01d"}, +] + +[[package]] +name = "types-requests" +version = "2.31.0.2" +description = "Typing stubs for requests" +optional = false +python-versions = "*" +files = [ + {file = "types-requests-2.31.0.2.tar.gz", hash = "sha256:6aa3f7faf0ea52d728bb18c0a0d1522d9bfd8c72d26ff6f61bfc3d06a411cf40"}, + {file = "types_requests-2.31.0.2-py3-none-any.whl", hash = "sha256:56d181c85b5925cbc59f4489a57e72a8b2166f18273fd8ba7b6fe0c0b986f12a"}, +] + +[package.dependencies] +types-urllib3 = "*" + +[[package]] +name = "types-setuptools" +version = "68.1.0.0" +description = "Typing stubs for setuptools" +optional = false +python-versions = "*" +files = [ + {file = "types-setuptools-68.1.0.0.tar.gz", hash = "sha256:2bc9b0c0818f77bdcec619970e452b320a423bb3ac074f5f8bc9300ac281c4ae"}, + {file = "types_setuptools-68.1.0.0-py3-none-any.whl", hash = "sha256:0c1618fb14850cb482adbec602bbb519c43f24942d66d66196bc7528320f33b1"}, +] + +[[package]] +name = "types-six" +version = "1.16.21.9" +description = "Typing stubs for six" +optional = false +python-versions = "*" +files = [ + {file = "types-six-1.16.21.9.tar.gz", hash = "sha256:746e6c25b8c48b3c8ab9efe7f68022839111de423d35ba4b206b88b12d75f233"}, + {file = "types_six-1.16.21.9-py3-none-any.whl", hash = "sha256:1591a09430a3035326da5fdb71692d0b3cc36b25a440cc5929ca6241f3984705"}, +] + +[[package]] +name = "types-ujson" +version = "5.8.0.1" +description = "Typing stubs for ujson" +optional = false +python-versions = "*" +files = [ + {file = "types-ujson-5.8.0.1.tar.gz", hash = "sha256:2b14388248ab4cd1f5efa8c464761112597ccd57c0d84238f73631abe0e20cfd"}, + {file = "types_ujson-5.8.0.1-py3-none-any.whl", hash = "sha256:1923f373ba5df0eaa4e3fe5c85dbf4c0b475e2cce3f77f5f4b773347ea1a62c9"}, +] + +[[package]] +name = "types-urllib3" +version = "1.26.25.14" +description = "Typing stubs for urllib3" +optional = false +python-versions = "*" +files = [ + {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, + {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, +] + [[package]] name = "typing-extensions" version = "4.7.1" @@ -859,6 +1240,23 @@ files = [ {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] +[[package]] +name = "urllib3" +version = "2.0.4" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, + {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "virtualenv" version = "20.24.3" @@ -879,7 +1277,44 @@ platformdirs = ">=3.9.1,<4" docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "werkzeug" +version = "2.3.7" +description = "The comprehensive WSGI web application library." +optional = true +python-versions = ">=3.8" +files = [ + {file = "werkzeug-2.3.7-py3-none-any.whl", hash = "sha256:effc12dba7f3bd72e605ce49807bbe692bd729c3bb122a3b91747a6ae77df528"}, + {file = "werkzeug-2.3.7.tar.gz", hash = "sha256:2b8c0e447b4b9dbcc85dd97b6eeb4dcbaf6c8b6c3be0bd654e25553e0a2157d8"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "zipp" +version = "3.16.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = true +python-versions = ">=3.8" +files = [ + {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, + {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[extras] +fastapi = ["fastapi", "httpx", "starlette"] +flask = ["flask", "requests"] +starlette = ["httpx", "starlette"] + [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "2f5b3618ab737fec32c62a45cb403ab70ddf8b0fef62a502885c72afdcf6e205" +content-hash = "e38139db21bd37b5a0074324e35cd61a9edeaad139c5183ed18c72956b2f711a" diff --git a/pyproject.toml b/pyproject.toml index e9d35ff..7f965d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,14 @@ readme = "README.md" [tool.poetry.dependencies] backoff = ">=2.0.0" -httpx = ">=0.22.0" python = ">=3.8,<4.0" -starlette = ">=0.21.0" + +# Optional dependencies, included in extras +fastapi = { version = ">=0.87.0", optional = true } +flask = { version = ">=2.0.0", optional = true } +httpx = { version = ">=0.22.0", optional = true } +requests = { version = ">=2.26.0", optional = true } +starlette = { version = ">=0.21.0", optional = true } [tool.poetry.group.dev.dependencies] black = "^23.3.0" @@ -29,9 +34,22 @@ pytest-asyncio = "^0.21.0" pytest-cov = "^4.1.0" pytest-httpx = "^0.22.0" pytest-mock = "^3.11.1" +requests-mock = "^1.11.0" + +[tool.poetry.group.types.dependencies] +types-colorama = "*" +types-docutils = "*" +types-pygments = "*" +types-pyyaml = "*" +types-requests = "*" +types-setuptools = "*" +types-six = "*" +types-ujson = "*" -[tool.poetry.group.fastapi.dependencies] -fastapi = ">=0.87.0" +[tool.poetry.extras] +fastapi = ["fastapi", "starlette", "httpx"] +flask = ["flask", "requests"] +starlette = ["starlette", "httpx"] [tool.poetry-dynamic-versioning] enable = true diff --git a/tests/conftest.py b/tests/conftest.py index fd6a2ac..49a59f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,7 @@ +import asyncio import os +from asyncio import AbstractEventLoop +from typing import Iterator import pytest @@ -12,3 +15,11 @@ def pytest_exception_interact(call): @pytest.hookimpl(tryfirst=True) def pytest_internalerror(excinfo): raise excinfo.value + + +@pytest.fixture(scope="module") +def event_loop() -> Iterator[AbstractEventLoop]: + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() diff --git a/tests/test_client.py b/tests/test_client_asyncio.py similarity index 64% rename from tests/test_client.py rename to tests/test_client_asyncio.py index 717a9b6..4e6810b 100644 --- a/tests/test_client.py +++ b/tests/test_client_asyncio.py @@ -2,7 +2,7 @@ import asyncio import json -from typing import TYPE_CHECKING, AsyncIterator +from typing import TYPE_CHECKING import pytest from pytest_httpx import HTTPXMock @@ -10,19 +10,16 @@ if TYPE_CHECKING: - from apitally.client import ApitallyClient + from apitally.client.asyncio import ApitallyClient CLIENT_ID = "76b5cb91-a0a4-4ea0-a894-57d2b9fcb2c9" ENV = "default" -@pytest.fixture() -async def client(mocker: MockerFixture) -> AsyncIterator[ApitallyClient]: - from apitally.client import ApitallyClient - - mocker.patch("apitally.client.ApitallyClient.start_sync_loop") - mocker.patch("apitally.client.ApitallyClient._run_sync_loop") +@pytest.fixture(scope="module") +async def client(module_mocker: MockerFixture) -> ApitallyClient: + from apitally.client.asyncio import ApitallyClient client = ApitallyClient(client_id=CLIENT_ID, env=ENV, enable_keys=True) client.request_logger.log_request( @@ -37,11 +34,23 @@ async def client(mocker: MockerFixture) -> AsyncIterator[ApitallyClient]: status_code=200, response_time=0.227, ) - yield client + return client + + +async def test_sync_loop(client: ApitallyClient, mocker: MockerFixture): + send_requests_data_mock = mocker.patch("apitally.client.asyncio.ApitallyClient.send_requests_data") + get_keys_mock = mocker.patch("apitally.client.asyncio.ApitallyClient.get_keys") + mocker.patch.object(client, "sync_interval", 0.05) + + client.start_sync_loop() + await asyncio.sleep(0.09) # Ensure loop enters first iteration + client.stop_sync_loop() # Should stop after first iteration + assert send_requests_data_mock.await_count >= 1 + assert get_keys_mock.await_count >= 2 async def test_send_requests_data(client: ApitallyClient, httpx_mock: HTTPXMock): - from apitally.client import HUB_BASE_URL, HUB_VERSION + from apitally.client.base import HUB_BASE_URL, HUB_VERSION httpx_mock.add_response() async with client.get_http_client() as http_client: @@ -55,7 +64,7 @@ async def test_send_requests_data(client: ApitallyClient, httpx_mock: HTTPXMock) async def test_send_app_info(client: ApitallyClient, httpx_mock: HTTPXMock): - from apitally.client import HUB_BASE_URL, HUB_VERSION + from apitally.client.base import HUB_BASE_URL, HUB_VERSION httpx_mock.add_response() app_info = {"paths": [], "client_version": "1.0.0", "starlette_version": "0.28.0", "python_version": "3.11.4"} @@ -70,11 +79,11 @@ async def test_send_app_info(client: ApitallyClient, httpx_mock: HTTPXMock): async def test_get_keys(client: ApitallyClient, httpx_mock: HTTPXMock): - from apitally.client import HUB_BASE_URL, HUB_VERSION + from apitally.client.base import HUB_BASE_URL, HUB_VERSION httpx_mock.add_response(json={"salt": "x", "keys": {"x": {"key_id": 1, "expires_in_seconds": None}}}) - await client.get_keys() - await asyncio.sleep(0.01) + async with client.get_http_client() as http_client: + await client.get_keys(http_client) requests = httpx_mock.get_requests(url=f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/keys") assert len(requests) == 1 diff --git a/tests/test_keys.py b/tests/test_client_base.py similarity index 62% rename from tests/test_keys.py rename to tests/test_client_base.py index 0f1f45b..b2c3a61 100644 --- a/tests/test_keys.py +++ b/tests/test_client_base.py @@ -1,8 +1,37 @@ import pytest -def test_keys(): - from apitally.keys import KeyRegistry +def test_request_logger(): + from apitally.client.base import RequestLogger + + requests = RequestLogger() + requests.log_request( + method="GET", + path="/test", + status_code=200, + response_time=0.105, + ) + requests.log_request( + method="GET", + path="/test", + status_code=200, + response_time=0.227, + ) + assert len(requests.request_counts) == 1 + + data = requests.get_and_reset_requests() + assert len(requests.request_counts) == 0 + assert len(data) == 1 + assert data[0]["method"] == "GET" + assert data[0]["path"] == "/test" + assert data[0]["status_code"] == 200 + assert data[0]["request_count"] == 2 + assert data[0]["response_times"][100] == 1 + assert data[0]["response_times"][220] == 1 + + +def test_key_registry(): + from apitally.client.base import KeyRegistry keys = KeyRegistry() diff --git a/tests/test_client_threading.py b/tests/test_client_threading.py new file mode 100644 index 0000000..bd79aee --- /dev/null +++ b/tests/test_client_threading.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import pytest +import requests +from pytest_mock import MockerFixture +from requests_mock import Mocker + + +if TYPE_CHECKING: + from apitally.client.threading import ApitallyClient + + +CLIENT_ID = "76b5cb91-a0a4-4ea0-a894-57d2b9fcb2c9" +ENV = "default" + + +@pytest.fixture(scope="module") +def client() -> ApitallyClient: + from apitally.client.threading import ApitallyClient + + client = ApitallyClient(client_id=CLIENT_ID, env=ENV, enable_keys=True) + client.request_logger.log_request( + method="GET", + path="/test", + status_code=200, + response_time=0.105, + ) + client.request_logger.log_request( + method="GET", + path="/test", + status_code=200, + response_time=0.227, + ) + return client + + +def test_sync_loop(client: ApitallyClient, mocker: MockerFixture): + send_requests_data_mock = mocker.patch("apitally.client.threading.ApitallyClient.send_requests_data") + get_keys_mock = mocker.patch("apitally.client.threading.ApitallyClient.get_keys") + mocker.patch.object(client, "sync_interval", 0.05) + + client.start_sync_loop() + time.sleep(0.02) # Ensure loop enters first iteration + client.stop_sync_loop() # Should stop after first iteration + assert client._thread is None + assert send_requests_data_mock.call_count >= 1 + assert get_keys_mock.call_count >= 2 + + +def test_send_requests_data(client: ApitallyClient, requests_mock: Mocker): + from apitally.client.base import HUB_BASE_URL, HUB_VERSION + + mock = requests_mock.register_uri("POST", f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/requests") + with requests.Session() as session: + client.send_requests_data(session) + + assert len(mock.request_history) == 1 + request_data = mock.request_history[0].json() + assert len(request_data["requests"]) == 1 + assert request_data["requests"][0]["request_count"] == 2 + + +def test_send_app_info(client: ApitallyClient, requests_mock: Mocker): + from apitally.client.base import HUB_BASE_URL, HUB_VERSION + + mock = requests_mock.register_uri("POST", f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/info") + app_info = {"paths": [], "client_version": "1.0.0", "starlette_version": "0.28.0", "python_version": "3.11.4"} + client.send_app_info(app_info=app_info) + + assert len(mock.request_history) == 1 + request_data = mock.request_history[0].json() + assert request_data["paths"] == [] + assert request_data["client_version"] == "1.0.0" + + +def test_get_keys(client: ApitallyClient, requests_mock: Mocker): + from apitally.client.base import HUB_BASE_URL, HUB_VERSION + + mock = requests_mock.register_uri( + "GET", + f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/keys", + json={"salt": "x", "keys": {"x": {"key_id": 1, "expires_in_seconds": None}}}, + ) + with requests.Session() as session: + client.get_keys(session) + + assert len(mock.request_history) == 1 + assert len(client.key_registry.keys) == 1 diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index b6e8bbc..89977bd 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from fastapi import FastAPI -from apitally.keys import KeyInfo # import here to avoid pydantic error +from apitally.client.base import KeyInfo # import here to avoid pydantic error @pytest.fixture() @@ -42,7 +42,7 @@ def baz(): def test_api_key_auth(app_with_auth: FastAPI, mocker: MockerFixture): from starlette.testclient import TestClient - from apitally.keys import KeyInfo, KeyRegistry + from apitally.client.base import KeyInfo, KeyRegistry client = TestClient(app_with_auth) key_registry = KeyRegistry() diff --git a/tests/test_requests.py b/tests/test_requests.py deleted file mode 100644 index e02b247..0000000 --- a/tests/test_requests.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - - -if TYPE_CHECKING: - from apitally.requests import RequestLogger - - -@pytest.fixture() -def requests() -> RequestLogger: - from apitally.requests import RequestLogger - - requests = RequestLogger() - requests.log_request( - method="GET", - path="/test", - status_code=200, - response_time=0.105, - ) - requests.log_request( - method="GET", - path="/test", - status_code=200, - response_time=0.227, - ) - return requests - - -async def test_get_and_reset_requests(requests: RequestLogger): - assert len(requests.request_count) > 0 - - data = requests.get_and_reset_requests() - assert len(requests.request_count) == 0 - assert len(data) == 1 - assert data[0]["method"] == "GET" - assert data[0]["path"] == "/test" - assert data[0]["status_code"] == 200 - assert data[0]["request_count"] == 2 - assert data[0]["response_times"][100] == 1 - assert data[0]["response_times"][220] == 1 diff --git a/tests/test_starlette.py b/tests/test_starlette.py index 2b35858..bd091a2 100644 --- a/tests/test_starlette.py +++ b/tests/test_starlette.py @@ -1,9 +1,7 @@ from __future__ import annotations -import asyncio -from asyncio import AbstractEventLoop from importlib.util import find_spec -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest @@ -24,21 +22,13 @@ ENV = "default" -@pytest.fixture(scope="module") -def event_loop() -> Iterator[AbstractEventLoop]: - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() - - @pytest.fixture( scope="module", params=["starlette", "fastapi"] if find_spec("fastapi") is not None else ["starlette"], ) async def app(request: FixtureRequest, module_mocker: MockerFixture) -> Starlette: - module_mocker.patch("apitally.client.ApitallyClient.start_sync_loop") - module_mocker.patch("apitally.client.ApitallyClient.send_app_info") + module_mocker.patch("apitally.client.asyncio.ApitallyClient.start_sync_loop") + module_mocker.patch("apitally.client.asyncio.ApitallyClient.send_app_info") if request.param == "starlette": return get_starlette_app() elif request.param == "fastapi": @@ -154,8 +144,7 @@ def baz(): def test_middleware_param_validation(app: Starlette): - from apitally.client import ApitallyClient - from apitally.starlette import ApitallyMiddleware + from apitally.starlette import ApitallyClient, ApitallyMiddleware ApitallyClient._instance = None @@ -166,13 +155,13 @@ def test_middleware_param_validation(app: Starlette): with pytest.raises(ValueError): ApitallyMiddleware(app, client_id=CLIENT_ID, app_version="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") with pytest.raises(ValueError): - ApitallyMiddleware(app, client_id=CLIENT_ID, send_every=1) + ApitallyMiddleware(app, client_id=CLIENT_ID, sync_interval=1) def test_middleware_requests_ok(app: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient - mock = mocker.patch("apitally.requests.RequestLogger.log_request") + mock = mocker.patch("apitally.client.base.RequestLogger.log_request") client = TestClient(app) background_task_mock: MagicMock = app.state.background_task_mock # type: ignore[attr-defined] @@ -203,8 +192,8 @@ def test_middleware_requests_ok(app: Starlette, mocker: MockerFixture): def test_middleware_requests_error(app: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient - mocker.patch("apitally.client.ApitallyClient.send_app_info") - mock = mocker.patch("apitally.requests.RequestLogger.log_request") + mocker.patch("apitally.starlette.ApitallyClient.send_app_info") + mock = mocker.patch("apitally.client.base.RequestLogger.log_request") client = TestClient(app, raise_server_exceptions=False) response = client.post("/baz/") @@ -220,8 +209,8 @@ def test_middleware_requests_error(app: Starlette, mocker: MockerFixture): def test_middleware_requests_unhandled(app: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient - mocker.patch("apitally.client.ApitallyClient.send_app_info") - mock = mocker.patch("apitally.requests.RequestLogger.log_request") + mocker.patch("apitally.starlette.ApitallyClient.send_app_info") + mock = mocker.patch("apitally.client.base.RequestLogger.log_request") client = TestClient(app) response = client.post("/xxx/") @@ -232,7 +221,7 @@ def test_middleware_requests_unhandled(app: Starlette, mocker: MockerFixture): def test_keys_auth_backend(app_with_auth: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient - from apitally.keys import KeyInfo, KeyRegistry + from apitally.client.base import KeyInfo, KeyRegistry client = TestClient(app_with_auth) key_registry = KeyRegistry() From 15235bf976afc68f10c6f4a1f0051c0d521fc599 Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Wed, 16 Aug 2023 23:07:02 +1000 Subject: [PATCH 2/7] Add middleware for Flask --- apitally/client/base.py | 2 +- apitally/client/utils.py | 16 + apitally/flask.py | 136 ++++++++ apitally/starlette.py | 15 +- notebooks/flask.ipynb | 145 +++++++++ poetry.lock | 688 ++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + tests/test_flask.py | 89 +++++ tests/test_starlette.py | 3 +- 9 files changed, 1076 insertions(+), 19 deletions(-) create mode 100644 apitally/client/utils.py create mode 100644 notebooks/flask.ipynb create mode 100644 tests/test_flask.py diff --git a/apitally/client/base.py b/apitally/client/base.py index fd7feb7..1bdb91b 100644 --- a/apitally/client/base.py +++ b/apitally/client/base.py @@ -97,7 +97,7 @@ def __init__(self) -> None: self._lock = threading.Lock() def log_request(self, method: str, path: str, status_code: int, response_time: float) -> None: - request_info = RequestInfo(method=method, path=path, status_code=status_code) + request_info = RequestInfo(method=method.upper(), path=path, status_code=status_code) response_time_ms_bin = int(floor(response_time / 0.01) * 10) # In ms, rounded down to nearest 10ms with self._lock: self.request_counts[request_info] += 1 diff --git a/apitally/client/utils.py b/apitally/client/utils.py new file mode 100644 index 0000000..03f55df --- /dev/null +++ b/apitally/client/utils.py @@ -0,0 +1,16 @@ +import re +from typing import Optional +from uuid import UUID + + +def validate_client_params(client_id: str, env: str, app_version: Optional[str], sync_interval: float) -> None: + try: + UUID(client_id) + except ValueError: + raise ValueError(f"invalid client_id '{client_id}' (expected hexadecimal UUID format)") + if re.match(r"^[\w-]{1,32}$", env) is None: + raise ValueError(f"invalid env '{env}' (expected 1-32 alphanumeric lowercase characters and hyphens only)") + if app_version is not None and len(app_version) > 32: + raise ValueError(f"invalid app_version '{app_version}' (expected 1-32 characters)") + if sync_interval < 10: + raise ValueError("sync_interval has to be greater or equal to 10 seconds") diff --git a/apitally/flask.py b/apitally/flask.py index e69de29..0bb8dc1 100644 --- a/apitally/flask.py +++ b/apitally/flask.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import sys +import time +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple + +import flask +from werkzeug.exceptions import NotFound +from werkzeug.test import Client + +import apitally +from apitally.client.threading import ApitallyClient +from apitally.client.utils import validate_client_params + + +if TYPE_CHECKING: + from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment + from werkzeug.routing.map import Map + + +class ApitallyMiddleware: + def __init__( + self, + app: WSGIApplication, + client_id: str, + env: str = "default", + app_version: Optional[str] = None, + enable_keys: bool = False, + sync_interval: float = 60, + filter_unhandled_paths: bool = True, + openapi_url: Optional[str] = "/openapi.json", + url_map: Optional[Map] = None, + ) -> None: + self.app = app + self.url_map = url_map or self.get_url_map() + if self.url_map is None: + raise ValueError( + "Could not extract url_map from app. Please provide it as an argument to ApitallyMiddleware." + ) + self.filter_unhandled_paths = filter_unhandled_paths + validate_client_params(client_id=client_id, env=env, app_version=app_version, sync_interval=sync_interval) + self.client = ApitallyClient(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) + self.client.send_app_info(app_info=_get_app_info(self.app, self.url_map, app_version, openapi_url)) + self.client.start_sync_loop() + + def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: + status_code = 200 + + def catching_start_response(status: str, headers, exc_info=None): + nonlocal status_code + status_code = int(status.split(" ")[0]) + return start_response(status, headers, exc_info) + + start_time = time.perf_counter() + response = self.app(environ, catching_start_response) + self.log_request( + environ=environ, + status_code=status_code, + response_time=time.perf_counter() - start_time, + ) + return response + + def log_request(self, environ: WSGIEnvironment, status_code: int, response_time: float) -> None: + path_template, is_handled_path = self.get_path_template(environ) + if is_handled_path or not self.filter_unhandled_paths: + self.client.request_logger.log_request( + method=environ["REQUEST_METHOD"], + path=path_template, + status_code=status_code, + response_time=response_time, + ) + + def get_url_map(self) -> Optional[Map]: + if hasattr(self.app, "url_map"): + return self.app.url_map + elif hasattr(self.app, "__self__") and hasattr(self.app.__self__, "url_map"): + return self.app.__self__.url_map + return None + + def get_path_template(self, environ: WSGIEnvironment) -> Tuple[str, bool]: + if self.url_map is None: + return environ["PATH_INFO"], False # pragma: no cover + url_adapter = self.url_map.bind_to_environ(environ) + try: + endpoint, _ = url_adapter.match() + rule = self.url_map._rules_by_endpoint[endpoint][0] + return rule.rule, True + except NotFound: + return environ["PATH_INFO"], False + + +def _get_app_info( + app: WSGIApplication, + url_map: Map, + app_version: Optional[str], + openapi_url: Optional[str], +) -> Dict[str, Any]: + app_info: Dict[str, Any] = {} + if openapi := _get_openapi(app, openapi_url): + app_info["openapi"] = openapi + elif endpoints := _get_endpoint_info(url_map): + app_info["paths"] = endpoints + app_info["versions"] = _get_versions(app_version) + app_info["client"] = "apitally-python" + return app_info + + +def _get_endpoint_info(url_map: Map) -> List[Dict[str, str]]: + return [ + {"path": rule.rule, "method": method} + for rule in url_map.iter_rules() + if rule.methods is not None and rule.rule != "/static/" + for method in rule.methods + if method not in ["HEAD", "OPTIONS"] + ] + + +def _get_openapi(app: WSGIApplication, openapi_url: Optional[str]) -> Optional[str]: + if not openapi_url: + return None + client = Client(app) + response = client.get(openapi_url) + if response.status_code != 200: + return None + return response.get_data(as_text=True) + + +def _get_versions(app_version: Optional[str]) -> Dict[str, str]: + versions = { + "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "apitally": apitally.__version__, + "flask": flask.__version__, + } + if app_version: + versions["app"] = app_version + return versions diff --git a/apitally/starlette.py b/apitally/starlette.py index b3be26b..3b03300 100644 --- a/apitally/starlette.py +++ b/apitally/starlette.py @@ -1,10 +1,8 @@ from __future__ import annotations -import re import sys import time from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -from uuid import UUID import starlette from httpx import HTTPStatusError @@ -26,6 +24,7 @@ import apitally from apitally.client.asyncio import ApitallyClient from apitally.client.base import KeyInfo +from apitally.client.utils import validate_client_params if TYPE_CHECKING: @@ -49,18 +48,8 @@ def __init__( filter_unhandled_paths: bool = True, openapi_url: Optional[str] = "/openapi.json", ) -> None: - try: - UUID(client_id) - except ValueError: - raise ValueError(f"invalid client_id '{client_id}' (expected hexadecimal UUID format)") - if re.match(r"^[\w-]{1,32}$", env) is None: - raise ValueError(f"invalid env '{env}' (expected 1-32 alphanumeric lowercase characters and hyphens only)") - if app_version is not None and len(app_version) > 32: - raise ValueError(f"invalid app_version '{app_version}' (expected 1-32 characters)") - if sync_interval < 10: - raise ValueError("sync_interval has to be greater or equal to 10 seconds") - self.filter_unhandled_paths = filter_unhandled_paths + validate_client_params(client_id=client_id, env=env, app_version=app_version, sync_interval=sync_interval) self.client = ApitallyClient(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) self.client.send_app_info(app_info=_get_app_info(app, app_version, openapi_url)) self.client.start_sync_loop() diff --git a/notebooks/flask.ipynb b/notebooks/flask.ipynb new file mode 100644 index 0000000..28b6f75 --- /dev/null +++ b/notebooks/flask.ipynb @@ -0,0 +1,145 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[MIDDLEWARE] GET /hello/ 200 0.00013494491577148438\n", + "200\n", + "b'Hello, world!'\n" + ] + } + ], + "source": [ + "from flask import Flask\n", + "from apitally.flask import ApitallyMiddleware\n", + "\n", + "app = Flask(\"Test App\")\n", + "app.wsgi_app = ApitallyMiddleware(app.wsgi_app)\n", + "\n", + "\n", + "@app.route(\"/hello/\")\n", + "def hello(name):\n", + " return f\"Hello, {name}!\"\n", + "\n", + "\n", + "client = app.test_client()\n", + "response = client.get(\"/hello/world\")\n", + "\n", + "print(response.status_code)\n", + "print(response.data)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "rule = list(app.url_map.iter_rules())[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'/static/'" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rule.rule" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'paths': [{'path': '/hello/', 'method': 'GET'}],\n", + " 'versions': {'python': '3.11.4', 'apitally': '0.0.0', 'flask': '2.3.2'},\n", + " 'client': 'apitally-python'}" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from apitally.flask import _get_app_info\n", + "\n", + "_get_app_info(app.wsgi_app, app.url_map, None, None)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[MIDDLEWARE] GET /hello/ 200 0.00013375282287597656\n" + ] + }, + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from werkzeug.test import Client\n", + "\n", + "client = Client(app.wsgi_app)\n", + "response = client.get(\"/hello/simon\")\n", + "response.status_code == 200" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/poetry.lock b/poetry.lock index f22faaf..9f715e4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -35,6 +35,45 @@ doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd- test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (<0.22)"] +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = "*" +files = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] + +[[package]] +name = "asttokens" +version = "2.2.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.2.1-py2.py3-none-any.whl", hash = "sha256:6b0ac9e93fb0335014d382b8fa9b3afa7df546984258005da0b9e7095b3deb1c"}, + {file = "asttokens-2.2.1.tar.gz", hash = "sha256:4622110b2a6f30b77e1473affaa97e711bc2f07d3f10848420ff1898edbe94f3"}, +] + +[package.dependencies] +six = "*" + +[package.extras] +test = ["astroid", "pytest"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +optional = false +python-versions = "*" +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] + [[package]] name = "backoff" version = "2.2.1" @@ -114,6 +153,82 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.4.0" @@ -234,6 +349,25 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "comm" +version = "0.1.4" +description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." +optional = false +python-versions = ">=3.6" +files = [ + {file = "comm-0.1.4-py3-none-any.whl", hash = "sha256:6d52794cba11b36ed9860999cd10fd02d6b2eac177068fdd585e1e2f8a96e67a"}, + {file = "comm-0.1.4.tar.gz", hash = "sha256:354e40a59c9dd6db50c5cc6b4acc887d82e9603787f83b68c01a80a923984d15"}, +] + +[package.dependencies] +traitlets = ">=4" + +[package.extras] +lint = ["black (>=22.6.0)", "mdformat (>0.7)", "mdformat-gfm (>=0.3.5)", "ruff (>=0.0.156)"] +test = ["pytest"] +typing = ["mypy (>=0.990)"] + [[package]] name = "coverage" version = "7.3.0" @@ -301,6 +435,44 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "debugpy" +version = "1.6.7.post1" +description = "An implementation of the Debug Adapter Protocol for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "debugpy-1.6.7.post1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:903bd61d5eb433b6c25b48eae5e23821d4c1a19e25c9610205f5aeaccae64e32"}, + {file = "debugpy-1.6.7.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d16882030860081e7dd5aa619f30dec3c2f9a421e69861125f83cc372c94e57d"}, + {file = "debugpy-1.6.7.post1-cp310-cp310-win32.whl", hash = "sha256:eea8d8cfb9965ac41b99a61f8e755a8f50e9a20330938ad8271530210f54e09c"}, + {file = "debugpy-1.6.7.post1-cp310-cp310-win_amd64.whl", hash = "sha256:85969d864c45f70c3996067cfa76a319bae749b04171f2cdeceebe4add316155"}, + {file = "debugpy-1.6.7.post1-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:890f7ab9a683886a0f185786ffbda3b46495c4b929dab083b8c79d6825832a52"}, + {file = "debugpy-1.6.7.post1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ac7a4dba28801d184b7fc0e024da2635ca87d8b0a825c6087bb5168e3c0d28"}, + {file = "debugpy-1.6.7.post1-cp37-cp37m-win32.whl", hash = "sha256:3370ef1b9951d15799ef7af41f8174194f3482ee689988379763ef61a5456426"}, + {file = "debugpy-1.6.7.post1-cp37-cp37m-win_amd64.whl", hash = "sha256:65b28435a17cba4c09e739621173ff90c515f7b9e8ea469b92e3c28ef8e5cdfb"}, + {file = "debugpy-1.6.7.post1-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:92b6dae8bfbd497c90596bbb69089acf7954164aea3228a99d7e43e5267f5b36"}, + {file = "debugpy-1.6.7.post1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72f5d2ecead8125cf669e62784ef1e6300f4067b0f14d9f95ee00ae06fc7c4f7"}, + {file = "debugpy-1.6.7.post1-cp38-cp38-win32.whl", hash = "sha256:f0851403030f3975d6e2eaa4abf73232ab90b98f041e3c09ba33be2beda43fcf"}, + {file = "debugpy-1.6.7.post1-cp38-cp38-win_amd64.whl", hash = "sha256:3de5d0f97c425dc49bce4293df6a04494309eedadd2b52c22e58d95107e178d9"}, + {file = "debugpy-1.6.7.post1-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:38651c3639a4e8bbf0ca7e52d799f6abd07d622a193c406be375da4d510d968d"}, + {file = "debugpy-1.6.7.post1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:038c51268367c9c935905a90b1c2d2dbfe304037c27ba9d19fe7409f8cdc710c"}, + {file = "debugpy-1.6.7.post1-cp39-cp39-win32.whl", hash = "sha256:4b9eba71c290852f959d2cf8a03af28afd3ca639ad374d393d53d367f7f685b2"}, + {file = "debugpy-1.6.7.post1-cp39-cp39-win_amd64.whl", hash = "sha256:973a97ed3b434eab0f792719a484566c35328196540676685c975651266fccf9"}, + {file = "debugpy-1.6.7.post1-py2.py3-none-any.whl", hash = "sha256:1093a5c541af079c13ac8c70ab8b24d1d35c8cacb676306cf11e57f699c02926"}, + {file = "debugpy-1.6.7.post1.zip", hash = "sha256:fe87ec0182ef624855d05e6ed7e0b7cb1359d2ffa2a925f8ec2d22e98b75d0ca"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "distlib" version = "0.3.7" @@ -326,6 +498,20 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "executing" +version = "1.2.0" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = "*" +files = [ + {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, + {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, +] + +[package.extras] +tests = ["asttokens", "littleutils", "pytest", "rich"] + [[package]] name = "fastapi" version = "0.101.1" @@ -467,7 +653,7 @@ files = [ name = "importlib-metadata" version = "6.8.0" description = "Read metadata from Python packages" -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, @@ -493,6 +679,78 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "ipykernel" +version = "6.25.1" +description = "IPython Kernel for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ipykernel-6.25.1-py3-none-any.whl", hash = "sha256:c8a2430b357073b37c76c21c52184db42f6b4b0e438e1eb7df3c4440d120497c"}, + {file = "ipykernel-6.25.1.tar.gz", hash = "sha256:050391364c0977e768e354bdb60cbbfbee7cbb943b1af1618382021136ffd42f"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "platform_system == \"Darwin\""} +comm = ">=0.1.1" +debugpy = ">=1.6.5" +ipython = ">=7.23.1" +jupyter-client = ">=6.1.12" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +matplotlib-inline = ">=0.1" +nest-asyncio = "*" +packaging = "*" +psutil = "*" +pyzmq = ">=20" +tornado = ">=6.1" +traitlets = ">=5.4.0" + +[package.extras] +cov = ["coverage[toml]", "curio", "matplotlib", "pytest-cov", "trio"] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] +pyqt5 = ["pyqt5"] +pyside6 = ["pyside6"] +test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0)", "pytest-asyncio", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "ipython" +version = "8.12.2" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ipython-8.12.2-py3-none-any.whl", hash = "sha256:ea8801f15dfe4ffb76dea1b09b847430ffd70d827b41735c64a0638a04103bfc"}, + {file = "ipython-8.12.2.tar.gz", hash = "sha256:c7b80eb7f5a855a88efc971fda506ff7a91c280b42cdae26643e0f601ea281ea"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] + [[package]] name = "itsdangerous" version = "2.1.2" @@ -504,6 +762,25 @@ files = [ {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, ] +[[package]] +name = "jedi" +version = "0.19.0" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.0-py2.py3-none-any.whl", hash = "sha256:cb8ce23fbccff0025e9386b5cf85e892f94c9b822378f8da49970471335ac64e"}, + {file = "jedi-0.19.0.tar.gz", hash = "sha256:bcf9894f1753969cbac8022a8c2eaee06bfa3724e4192470aaffe7eb6272b0c4"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + [[package]] name = "jinja2" version = "3.1.2" @@ -521,6 +798,49 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jupyter-client" +version = "8.3.0" +description = "Jupyter protocol implementation and client libraries" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_client-8.3.0-py3-none-any.whl", hash = "sha256:7441af0c0672edc5d28035e92ba5e32fadcfa8a4e608a434c228836a89df6158"}, + {file = "jupyter_client-8.3.0.tar.gz", hash = "sha256:3af69921fe99617be1670399a0b857ad67275eefcfa291e2c81a160b7b650f5f"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""} +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +python-dateutil = ">=2.8.2" +pyzmq = ">=23.0" +tornado = ">=6.2" +traitlets = ">=5.3" + +[package.extras] +docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] + +[[package]] +name = "jupyter-core" +version = "5.3.1" +description = "Jupyter core package. A base package on which Jupyter projects rely." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_core-5.3.1-py3-none-any.whl", hash = "sha256:ae9036db959a71ec1cac33081eeb040a79e681f08ab68b0883e9a676c7a90dce"}, + {file = "jupyter_core-5.3.1.tar.gz", hash = "sha256:5ba5c7938a7f97a6b0481463f7ff0dbac7c15ba48cf46fa4035ca6e838aa1aba"}, +] + +[package.dependencies] +platformdirs = ">=2.5" +pywin32 = {version = ">=300", markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\""} +traitlets = ">=5.3" + +[package.extras] +docs = ["myst-parser", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "traitlets"] +test = ["ipykernel", "pre-commit", "pytest", "pytest-cov", "pytest-timeout"] + [[package]] name = "markupsafe" version = "2.1.3" @@ -580,6 +900,20 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "mypy" version = "1.5.0" @@ -632,6 +966,17 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nest-asyncio" +version = "1.5.7" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +files = [ + {file = "nest_asyncio-1.5.7-py3-none-any.whl", hash = "sha256:5301c82941b550b3123a1ea772ba9a1c80bad3a182be8c1a5ae6ad3be57a9657"}, + {file = "nest_asyncio-1.5.7.tar.gz", hash = "sha256:6a80f7b98f24d9083ed24608977c09dd608d83f91cccc24c9d2cba6d10e01c10"}, +] + [[package]] name = "nodeenv" version = "1.8.0" @@ -657,6 +1002,21 @@ files = [ {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + [[package]] name = "pathspec" version = "0.11.2" @@ -668,6 +1028,31 @@ files = [ {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +optional = false +python-versions = "*" +files = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] + [[package]] name = "platformdirs" version = "3.10.0" @@ -716,6 +1101,82 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "prompt-toolkit" +version = "3.0.39" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"}, + {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "psutil" +version = "5.9.5" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"}, + {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"}, + {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"}, + {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"}, + {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"}, + {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"}, + {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"}, + {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pydantic" version = "2.1.1" @@ -848,6 +1309,20 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pytest" version = "7.4.0" @@ -941,6 +1416,43 @@ pytest = ">=5.0" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -990,6 +1502,111 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "pyzmq" +version = "25.1.1" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyzmq-25.1.1-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:381469297409c5adf9a0e884c5eb5186ed33137badcbbb0560b86e910a2f1e76"}, + {file = "pyzmq-25.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:955215ed0604dac5b01907424dfa28b40f2b2292d6493445dd34d0dfa72586a8"}, + {file = "pyzmq-25.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:985bbb1316192b98f32e25e7b9958088431d853ac63aca1d2c236f40afb17c83"}, + {file = "pyzmq-25.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:afea96f64efa98df4da6958bae37f1cbea7932c35878b185e5982821bc883369"}, + {file = "pyzmq-25.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76705c9325d72a81155bb6ab48d4312e0032bf045fb0754889133200f7a0d849"}, + {file = "pyzmq-25.1.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:77a41c26205d2353a4c94d02be51d6cbdf63c06fbc1295ea57dad7e2d3381b71"}, + {file = "pyzmq-25.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:12720a53e61c3b99d87262294e2b375c915fea93c31fc2336898c26d7aed34cd"}, + {file = "pyzmq-25.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:57459b68e5cd85b0be8184382cefd91959cafe79ae019e6b1ae6e2ba8a12cda7"}, + {file = "pyzmq-25.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:292fe3fc5ad4a75bc8df0dfaee7d0babe8b1f4ceb596437213821f761b4589f9"}, + {file = "pyzmq-25.1.1-cp310-cp310-win32.whl", hash = "sha256:35b5ab8c28978fbbb86ea54958cd89f5176ce747c1fb3d87356cf698048a7790"}, + {file = "pyzmq-25.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:11baebdd5fc5b475d484195e49bae2dc64b94a5208f7c89954e9e354fc609d8f"}, + {file = "pyzmq-25.1.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:d20a0ddb3e989e8807d83225a27e5c2eb2260eaa851532086e9e0fa0d5287d83"}, + {file = "pyzmq-25.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e1c1be77bc5fb77d923850f82e55a928f8638f64a61f00ff18a67c7404faf008"}, + {file = "pyzmq-25.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d89528b4943d27029a2818f847c10c2cecc79fa9590f3cb1860459a5be7933eb"}, + {file = "pyzmq-25.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90f26dc6d5f241ba358bef79be9ce06de58d477ca8485e3291675436d3827cf8"}, + {file = "pyzmq-25.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2b92812bd214018e50b6380ea3ac0c8bb01ac07fcc14c5f86a5bb25e74026e9"}, + {file = "pyzmq-25.1.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2f957ce63d13c28730f7fd6b72333814221c84ca2421298f66e5143f81c9f91f"}, + {file = "pyzmq-25.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:047a640f5c9c6ade7b1cc6680a0e28c9dd5a0825135acbd3569cc96ea00b2505"}, + {file = "pyzmq-25.1.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7f7e58effd14b641c5e4dec8c7dab02fb67a13df90329e61c869b9cc607ef752"}, + {file = "pyzmq-25.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c2910967e6ab16bf6fbeb1f771c89a7050947221ae12a5b0b60f3bca2ee19bca"}, + {file = "pyzmq-25.1.1-cp311-cp311-win32.whl", hash = "sha256:76c1c8efb3ca3a1818b837aea423ff8a07bbf7aafe9f2f6582b61a0458b1a329"}, + {file = "pyzmq-25.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:44e58a0554b21fc662f2712814a746635ed668d0fbc98b7cb9d74cb798d202e6"}, + {file = "pyzmq-25.1.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:e1ffa1c924e8c72778b9ccd386a7067cddf626884fd8277f503c48bb5f51c762"}, + {file = "pyzmq-25.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1af379b33ef33757224da93e9da62e6471cf4a66d10078cf32bae8127d3d0d4a"}, + {file = "pyzmq-25.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cff084c6933680d1f8b2f3b4ff5bbb88538a4aac00d199ac13f49d0698727ecb"}, + {file = "pyzmq-25.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2400a94f7dd9cb20cd012951a0cbf8249e3d554c63a9c0cdfd5cbb6c01d2dec"}, + {file = "pyzmq-25.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d81f1ddae3858b8299d1da72dd7d19dd36aab654c19671aa8a7e7fb02f6638a"}, + {file = "pyzmq-25.1.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:255ca2b219f9e5a3a9ef3081512e1358bd4760ce77828e1028b818ff5610b87b"}, + {file = "pyzmq-25.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a882ac0a351288dd18ecae3326b8a49d10c61a68b01419f3a0b9a306190baf69"}, + {file = "pyzmq-25.1.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:724c292bb26365659fc434e9567b3f1adbdb5e8d640c936ed901f49e03e5d32e"}, + {file = "pyzmq-25.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ca1ed0bb2d850aa8471387882247c68f1e62a4af0ce9c8a1dbe0d2bf69e41fb"}, + {file = "pyzmq-25.1.1-cp312-cp312-win32.whl", hash = "sha256:b3451108ab861040754fa5208bca4a5496c65875710f76789a9ad27c801a0075"}, + {file = "pyzmq-25.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:eadbefd5e92ef8a345f0525b5cfd01cf4e4cc651a2cffb8f23c0dd184975d787"}, + {file = "pyzmq-25.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db0b2af416ba735c6304c47f75d348f498b92952f5e3e8bff449336d2728795d"}, + {file = "pyzmq-25.1.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c133e93b405eb0d36fa430c94185bdd13c36204a8635470cccc200723c13bb"}, + {file = "pyzmq-25.1.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:273bc3959bcbff3f48606b28229b4721716598d76b5aaea2b4a9d0ab454ec062"}, + {file = "pyzmq-25.1.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cbc8df5c6a88ba5ae385d8930da02201165408dde8d8322072e3e5ddd4f68e22"}, + {file = "pyzmq-25.1.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:18d43df3f2302d836f2a56f17e5663e398416e9dd74b205b179065e61f1a6edf"}, + {file = "pyzmq-25.1.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:73461eed88a88c866656e08f89299720a38cb4e9d34ae6bf5df6f71102570f2e"}, + {file = "pyzmq-25.1.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:34c850ce7976d19ebe7b9d4b9bb8c9dfc7aac336c0958e2651b88cbd46682123"}, + {file = "pyzmq-25.1.1-cp36-cp36m-win32.whl", hash = "sha256:d2045d6d9439a0078f2a34b57c7b18c4a6aef0bee37f22e4ec9f32456c852c71"}, + {file = "pyzmq-25.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:458dea649f2f02a0b244ae6aef8dc29325a2810aa26b07af8374dc2a9faf57e3"}, + {file = "pyzmq-25.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7cff25c5b315e63b07a36f0c2bab32c58eafbe57d0dce61b614ef4c76058c115"}, + {file = "pyzmq-25.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1579413ae492b05de5a6174574f8c44c2b9b122a42015c5292afa4be2507f28"}, + {file = "pyzmq-25.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3d0a409d3b28607cc427aa5c30a6f1e4452cc44e311f843e05edb28ab5e36da0"}, + {file = "pyzmq-25.1.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21eb4e609a154a57c520e3d5bfa0d97e49b6872ea057b7c85257b11e78068222"}, + {file = "pyzmq-25.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:034239843541ef7a1aee0c7b2cb7f6aafffb005ede965ae9cbd49d5ff4ff73cf"}, + {file = "pyzmq-25.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f8115e303280ba09f3898194791a153862cbf9eef722ad8f7f741987ee2a97c7"}, + {file = "pyzmq-25.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:1a5d26fe8f32f137e784f768143728438877d69a586ddeaad898558dc971a5ae"}, + {file = "pyzmq-25.1.1-cp37-cp37m-win32.whl", hash = "sha256:f32260e556a983bc5c7ed588d04c942c9a8f9c2e99213fec11a031e316874c7e"}, + {file = "pyzmq-25.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:abf34e43c531bbb510ae7e8f5b2b1f2a8ab93219510e2b287a944432fad135f3"}, + {file = "pyzmq-25.1.1-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:87e34f31ca8f168c56d6fbf99692cc8d3b445abb5bfd08c229ae992d7547a92a"}, + {file = "pyzmq-25.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c9c6c9b2c2f80747a98f34ef491c4d7b1a8d4853937bb1492774992a120f475d"}, + {file = "pyzmq-25.1.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5619f3f5a4db5dbb572b095ea3cb5cc035335159d9da950830c9c4db2fbb6995"}, + {file = "pyzmq-25.1.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a34d2395073ef862b4032343cf0c32a712f3ab49d7ec4f42c9661e0294d106f"}, + {file = "pyzmq-25.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25f0e6b78220aba09815cd1f3a32b9c7cb3e02cb846d1cfc526b6595f6046618"}, + {file = "pyzmq-25.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3669cf8ee3520c2f13b2e0351c41fea919852b220988d2049249db10046a7afb"}, + {file = "pyzmq-25.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2d163a18819277e49911f7461567bda923461c50b19d169a062536fffe7cd9d2"}, + {file = "pyzmq-25.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:df27ffddff4190667d40de7beba4a950b5ce78fe28a7dcc41d6f8a700a80a3c0"}, + {file = "pyzmq-25.1.1-cp38-cp38-win32.whl", hash = "sha256:a382372898a07479bd34bda781008e4a954ed8750f17891e794521c3e21c2e1c"}, + {file = "pyzmq-25.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:52533489f28d62eb1258a965f2aba28a82aa747202c8fa5a1c7a43b5db0e85c1"}, + {file = "pyzmq-25.1.1-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:03b3f49b57264909aacd0741892f2aecf2f51fb053e7d8ac6767f6c700832f45"}, + {file = "pyzmq-25.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:330f9e188d0d89080cde66dc7470f57d1926ff2fb5576227f14d5be7ab30b9fa"}, + {file = "pyzmq-25.1.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2ca57a5be0389f2a65e6d3bb2962a971688cbdd30b4c0bd188c99e39c234f414"}, + {file = "pyzmq-25.1.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d457aed310f2670f59cc5b57dcfced452aeeed77f9da2b9763616bd57e4dbaae"}, + {file = "pyzmq-25.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c56d748ea50215abef7030c72b60dd723ed5b5c7e65e7bc2504e77843631c1a6"}, + {file = "pyzmq-25.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8f03d3f0d01cb5a018debeb412441996a517b11c5c17ab2001aa0597c6d6882c"}, + {file = "pyzmq-25.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:820c4a08195a681252f46926de10e29b6bbf3e17b30037bd4250d72dd3ddaab8"}, + {file = "pyzmq-25.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:17ef5f01d25b67ca8f98120d5fa1d21efe9611604e8eb03a5147360f517dd1e2"}, + {file = "pyzmq-25.1.1-cp39-cp39-win32.whl", hash = "sha256:04ccbed567171579ec2cebb9c8a3e30801723c575601f9a990ab25bcac6b51e2"}, + {file = "pyzmq-25.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:e61f091c3ba0c3578411ef505992d356a812fb200643eab27f4f70eed34a29ef"}, + {file = "pyzmq-25.1.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ade6d25bb29c4555d718ac6d1443a7386595528c33d6b133b258f65f963bb0f6"}, + {file = "pyzmq-25.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0c95ddd4f6e9fca4e9e3afaa4f9df8552f0ba5d1004e89ef0a68e1f1f9807c7"}, + {file = "pyzmq-25.1.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48e466162a24daf86f6b5ca72444d2bf39a5e58da5f96370078be67c67adc978"}, + {file = "pyzmq-25.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abc719161780932c4e11aaebb203be3d6acc6b38d2f26c0f523b5b59d2fc1996"}, + {file = "pyzmq-25.1.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1ccf825981640b8c34ae54231b7ed00271822ea1c6d8ba1090ebd4943759abf5"}, + {file = "pyzmq-25.1.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c2f20ce161ebdb0091a10c9ca0372e023ce24980d0e1f810f519da6f79c60800"}, + {file = "pyzmq-25.1.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:deee9ca4727f53464daf089536e68b13e6104e84a37820a88b0a057b97bba2d2"}, + {file = "pyzmq-25.1.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:aa8d6cdc8b8aa19ceb319aaa2b660cdaccc533ec477eeb1309e2a291eaacc43a"}, + {file = "pyzmq-25.1.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019e59ef5c5256a2c7378f2fb8560fc2a9ff1d315755204295b2eab96b254d0a"}, + {file = "pyzmq-25.1.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:b9af3757495c1ee3b5c4e945c1df7be95562277c6e5bccc20a39aec50f826cd0"}, + {file = "pyzmq-25.1.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:548d6482dc8aadbe7e79d1b5806585c8120bafa1ef841167bc9090522b610fa6"}, + {file = "pyzmq-25.1.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:057e824b2aae50accc0f9a0570998adc021b372478a921506fddd6c02e60308e"}, + {file = "pyzmq-25.1.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2243700cc5548cff20963f0ca92d3e5e436394375ab8a354bbea2b12911b20b0"}, + {file = "pyzmq-25.1.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79986f3b4af059777111409ee517da24a529bdbd46da578b33f25580adcff728"}, + {file = "pyzmq-25.1.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:11d58723d44d6ed4dd677c5615b2ffb19d5c426636345567d6af82be4dff8a55"}, + {file = "pyzmq-25.1.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:49d238cf4b69652257db66d0c623cd3e09b5d2e9576b56bc067a396133a00d4a"}, + {file = "pyzmq-25.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fedbdc753827cf014c01dbbee9c3be17e5a208dcd1bf8641ce2cd29580d1f0d4"}, + {file = "pyzmq-25.1.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc16ac425cc927d0a57d242589f87ee093884ea4804c05a13834d07c20db203c"}, + {file = "pyzmq-25.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11c1d2aed9079c6b0c9550a7257a836b4a637feb334904610f06d70eb44c56d2"}, + {file = "pyzmq-25.1.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e8a701123029cc240cea61dd2d16ad57cab4691804143ce80ecd9286b464d180"}, + {file = "pyzmq-25.1.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:61706a6b6c24bdece85ff177fec393545a3191eeda35b07aaa1458a027ad1304"}, + {file = "pyzmq-25.1.1.tar.gz", hash = "sha256:259c22485b71abacdfa8bf79720cd7bcf4b9d128b30ea554f01ae71fdbfdaa23"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + [[package]] name = "requests" version = "2.31.0" @@ -1094,6 +1711,25 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] +[[package]] +name = "stack-data" +version = "0.6.2" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.2-py3-none-any.whl", hash = "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8"}, + {file = "stack_data-0.6.2.tar.gz", hash = "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "starlette" version = "0.27.0" @@ -1123,6 +1759,41 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "tornado" +version = "6.3.3" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">= 3.8" +files = [ + {file = "tornado-6.3.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:502fba735c84450974fec147340016ad928d29f1e91f49be168c0a4c18181e1d"}, + {file = "tornado-6.3.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:805d507b1f588320c26f7f097108eb4023bbaa984d63176d1652e184ba24270a"}, + {file = "tornado-6.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd19ca6c16882e4d37368e0152f99c099bad93e0950ce55e71daed74045908f"}, + {file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ac51f42808cca9b3613f51ffe2a965c8525cb1b00b7b2d56828b8045354f76a"}, + {file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71a8db65160a3c55d61839b7302a9a400074c9c753040455494e2af74e2501f2"}, + {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ceb917a50cd35882b57600709dd5421a418c29ddc852da8bcdab1f0db33406b0"}, + {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:7d01abc57ea0dbb51ddfed477dfe22719d376119844e33c661d873bf9c0e4a16"}, + {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9dc4444c0defcd3929d5c1eb5706cbe1b116e762ff3e0deca8b715d14bf6ec17"}, + {file = "tornado-6.3.3-cp38-abi3-win32.whl", hash = "sha256:65ceca9500383fbdf33a98c0087cb975b2ef3bfb874cb35b8de8740cf7f41bd3"}, + {file = "tornado-6.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:22d3c2fa10b5793da13c807e6fc38ff49a4f6e1e3868b0a6f4164768bb8e20f5"}, + {file = "tornado-6.3.3.tar.gz", hash = "sha256:e7d8db41c0181c80d76c982aacc442c0783a2c54d6400fe028954201a2e032fe"}, +] + +[[package]] +name = "traitlets" +version = "5.9.0" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.7" +files = [ + {file = "traitlets-5.9.0-py3-none-any.whl", hash = "sha256:9e6ec080259b9a5940c797d58b613b5e31441c2257b87c2e795c5228ae80d2d8"}, + {file = "traitlets-5.9.0.tar.gz", hash = "sha256:f6cde21a9c68cf756af02035f72d5a723bf607e862e7be33ece505abf4a3bad9"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] + [[package]] name = "types-colorama" version = "0.4.15.12" @@ -1277,6 +1948,17 @@ platformdirs = ">=3.9.1,<4" docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "wcwidth" +version = "0.2.6" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, + {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, +] + [[package]] name = "werkzeug" version = "2.3.7" @@ -1298,7 +1980,7 @@ watchdog = ["watchdog (>=2.3)"] name = "zipp" version = "3.16.2" description = "Backport of pathlib-compatible object wrapper for zip files" -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, @@ -1317,4 +1999,4 @@ starlette = ["httpx", "starlette"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "e38139db21bd37b5a0074324e35cd61a9edeaad139c5183ed18c72956b2f711a" +content-hash = "59a82596a7eab80938c7f2122ab29d16aa11b4d9b0ca1a98110f172ad8f0ec2e" diff --git a/pyproject.toml b/pyproject.toml index 7f965d5..a2c9d92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ black = "^23.3.0" mypy = "^1.4.1" pre-commit = "^3.3.3" ruff = "^0.0.275" +ipykernel = "^6.25.1" [tool.poetry.group.test.dependencies] pytest = "^7.4.0" diff --git a/tests/test_flask.py b/tests/test_flask.py new file mode 100644 index 0000000..27caadc --- /dev/null +++ b/tests/test_flask.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from importlib.util import find_spec +from typing import TYPE_CHECKING + +import pytest +from pytest_mock import MockerFixture + + +if find_spec("flask") is None: + pytest.skip("flask is not available", allow_module_level=True) + +if TYPE_CHECKING: + from flask import Flask + + +CLIENT_ID = "76b5cb91-a0a4-4ea0-a894-57d2b9fcb2c9" +ENV = "default" + + +@pytest.fixture(scope="module") +def app(module_mocker: MockerFixture) -> Flask: + from flask import Flask + + from apitally.flask import ApitallyMiddleware + + module_mocker.patch("apitally.client.threading.ApitallyClient._instance", None) + module_mocker.patch("apitally.client.threading.ApitallyClient.start_sync_loop") + module_mocker.patch("apitally.client.threading.ApitallyClient.send_app_info") + + app = Flask("test") + + @app.route("/foo//") + def foo_bar(bar: int): + return f"foo: {bar}" + + @app.route("/bar/", methods=["POST"]) + def bar(): + return "bar" + + @app.route("/baz/", methods=["PUT"]) + def baz(): + raise ValueError("baz") + + app.wsgi_app = ApitallyMiddleware(app.wsgi_app, client_id=CLIENT_ID, env=ENV) # type: ignore[method-assign] + return app + + +def test_middleware_requests_ok(app: Flask, mocker: MockerFixture): + mock = mocker.patch("apitally.client.base.RequestLogger.log_request") + client = app.test_client() + + response = client.get("/foo/123/") + assert response.status_code == 200 + mock.assert_called_once() + assert mock.call_args is not None + assert mock.call_args.kwargs["method"] == "GET" + assert mock.call_args.kwargs["path"] == "/foo//" + assert mock.call_args.kwargs["status_code"] == 200 + assert mock.call_args.kwargs["response_time"] > 0 + + response = client.post("/bar/") + assert response.status_code == 200 + assert mock.call_count == 2 + assert mock.call_args is not None + assert mock.call_args.kwargs["method"] == "POST" + + +def test_middleware_requests_error(app: Flask, mocker: MockerFixture): + mock = mocker.patch("apitally.client.base.RequestLogger.log_request") + client = app.test_client() + + response = client.put("/baz/") + assert response.status_code == 500 + mock.assert_called_once() + assert mock.call_args is not None + assert mock.call_args.kwargs["method"] == "PUT" + assert mock.call_args.kwargs["path"] == "/baz/" + assert mock.call_args.kwargs["status_code"] == 500 + assert mock.call_args.kwargs["response_time"] > 0 + + +def test_middleware_requests_unhandled(app: Flask, mocker: MockerFixture): + mock = mocker.patch("apitally.client.base.RequestLogger.log_request") + client = app.test_client() + + response = client.post("/xxx/") + assert response.status_code == 404 + mock.assert_not_called() diff --git a/tests/test_starlette.py b/tests/test_starlette.py index bd091a2..1963cf2 100644 --- a/tests/test_starlette.py +++ b/tests/test_starlette.py @@ -27,6 +27,7 @@ params=["starlette", "fastapi"] if find_spec("fastapi") is not None else ["starlette"], ) async def app(request: FixtureRequest, module_mocker: MockerFixture) -> Starlette: + module_mocker.patch("apitally.client.asyncio.ApitallyClient._instance", None) module_mocker.patch("apitally.client.asyncio.ApitallyClient.start_sync_loop") module_mocker.patch("apitally.client.asyncio.ApitallyClient.send_app_info") if request.param == "starlette": @@ -192,7 +193,6 @@ def test_middleware_requests_ok(app: Starlette, mocker: MockerFixture): def test_middleware_requests_error(app: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient - mocker.patch("apitally.starlette.ApitallyClient.send_app_info") mock = mocker.patch("apitally.client.base.RequestLogger.log_request") client = TestClient(app, raise_server_exceptions=False) @@ -209,7 +209,6 @@ def test_middleware_requests_error(app: Starlette, mocker: MockerFixture): def test_middleware_requests_unhandled(app: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient - mocker.patch("apitally.starlette.ApitallyClient.send_app_info") mock = mocker.patch("apitally.client.base.RequestLogger.log_request") client = TestClient(app) From 89c785e1c1067989f5284b7a14d0894262c48e09 Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Thu, 17 Aug 2023 14:19:18 +1000 Subject: [PATCH 3/7] Fix errors and error handling --- apitally/client/asyncio.py | 15 ++++++++------ apitally/client/base.py | 16 ++++++++++----- apitally/client/threading.py | 2 +- apitally/client/utils.py | 16 --------------- apitally/flask.py | 38 +++++++++++++++++++++--------------- apitally/starlette.py | 4 +--- tests/test_flask.py | 12 +++++++++++- tests/test_starlette.py | 15 -------------- 8 files changed, 55 insertions(+), 63 deletions(-) delete mode 100644 apitally/client/utils.py diff --git a/apitally/client/asyncio.py b/apitally/client/asyncio.py index 7eadafb..6ccba72 100644 --- a/apitally/client/asyncio.py +++ b/apitally/client/asyncio.py @@ -26,7 +26,7 @@ def __init__(self, client_id: str, env: str, enable_keys: bool = False, sync_int self._stop_sync_loop = False def get_http_client(self) -> httpx.AsyncClient: - return httpx.AsyncClient(base_url=self.hub_url) + return httpx.AsyncClient(base_url=self.hub_url, timeout=1) def start_sync_loop(self) -> None: self._stop_sync_loop = False @@ -34,8 +34,11 @@ def start_sync_loop(self) -> None: async def _run_sync_loop(self) -> None: if self.enable_keys: - async with self.get_http_client() as client: - await self.get_keys(client) + try: + async with self.get_http_client() as client: + await self.get_keys(client) + except Exception as e: + logger.exception(e) while not self._stop_sync_loop: try: await asyncio.sleep(self.sync_interval) @@ -58,13 +61,13 @@ async def send_requests_data(self, client: httpx.AsyncClient) -> None: await self._send_requests_data(client, payload) async def get_keys(self, client: httpx.AsyncClient) -> None: - response_data = await self._get_keys(client) - self.handle_keys_response(response_data) + if response_data := await self._get_keys(client): + self.handle_keys_response(response_data) @retry async def _send_app_info(self, payload: Dict[str, Any]) -> None: async with self.get_http_client() as client: - response = await client.post(url="/info", json=payload) + response = await client.post(url="/info", json=payload, timeout=1) if response.status_code == 404 and "Client ID" in response.text: self.stop_sync_loop() logger.error(f"Invalid Apitally client ID {self.client_id}") diff --git a/apitally/client/base.py b/apitally/client/base.py index 1bdb91b..58809ec 100644 --- a/apitally/client/base.py +++ b/apitally/client/base.py @@ -2,6 +2,7 @@ import logging import os +import re import threading from collections import Counter from dataclasses import dataclass, field @@ -9,7 +10,7 @@ from hashlib import scrypt from math import floor from typing import Any, Dict, List, Optional, Set, Type, TypeVar, cast -from uuid import uuid4 +from uuid import UUID, uuid4 logger = logging.getLogger(__name__) @@ -21,7 +22,7 @@ def handle_retry_giveup(details) -> None: # pragma: no cover - logger.error("Apitally client failed to sync with hub: {target.__name__}: {exception}".format(**details)) + logger.exception("Apitally client failed to sync with hub: {target.__name__}(): {exception}".format(**details)) class ApitallyClientBase: @@ -38,6 +39,14 @@ def __new__(cls, *args, **kwargs) -> ApitallyClientBase: def __init__(self, client_id: str, env: str, enable_keys: bool = False, sync_interval: float = 60) -> None: if hasattr(self, "client_id"): raise RuntimeError("Apitally client is already initialized") # pragma: no cover + try: + UUID(client_id) + except ValueError: + raise ValueError(f"invalid client_id '{client_id}' (expected hexadecimal UUID format)") + if re.match(r"^[\w-]{1,32}$", env) is None: + raise ValueError(f"invalid env '{env}' (expected 1-32 alphanumeric lowercase characters and hyphens only)") + if sync_interval < 10: + raise ValueError("sync_interval has to be greater or equal to 10 seconds") self.client_id = client_id self.env = env @@ -57,9 +66,6 @@ def get_instance(cls: Type[TApitallyClient]) -> TApitallyClient: def hub_url(self) -> str: return f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}" - def send_app_info(self, app_info: Dict[str, Any]) -> None: - raise NotImplementedError # pragma: no cover - def get_info_payload(self, app_info: Dict[str, Any]) -> Dict[str, Any]: payload = { "instance_uuid": self.instance_uuid, diff --git a/apitally/client/threading.py b/apitally/client/threading.py index acd2fc9..9dd4091 100644 --- a/apitally/client/threading.py +++ b/apitally/client/threading.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) retry = backoff.on_exception( backoff.expo, - requests.HTTPError, + requests.RequestException, max_tries=3, on_giveup=handle_retry_giveup, raise_on_giveup=False, diff --git a/apitally/client/utils.py b/apitally/client/utils.py deleted file mode 100644 index 03f55df..0000000 --- a/apitally/client/utils.py +++ /dev/null @@ -1,16 +0,0 @@ -import re -from typing import Optional -from uuid import UUID - - -def validate_client_params(client_id: str, env: str, app_version: Optional[str], sync_interval: float) -> None: - try: - UUID(client_id) - except ValueError: - raise ValueError(f"invalid client_id '{client_id}' (expected hexadecimal UUID format)") - if re.match(r"^[\w-]{1,32}$", env) is None: - raise ValueError(f"invalid env '{env}' (expected 1-32 alphanumeric lowercase characters and hyphens only)") - if app_version is not None and len(app_version) > 32: - raise ValueError(f"invalid app_version '{app_version}' (expected 1-32 characters)") - if sync_interval < 10: - raise ValueError("sync_interval has to be greater or equal to 10 seconds") diff --git a/apitally/flask.py b/apitally/flask.py index 0bb8dc1..d9a0e5f 100644 --- a/apitally/flask.py +++ b/apitally/flask.py @@ -2,6 +2,7 @@ import sys import time +from threading import Timer from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple import flask @@ -10,7 +11,6 @@ import apitally from apitally.client.threading import ApitallyClient -from apitally.client.utils import validate_client_params if TYPE_CHECKING: @@ -27,22 +27,29 @@ def __init__( app_version: Optional[str] = None, enable_keys: bool = False, sync_interval: float = 60, - filter_unhandled_paths: bool = True, openapi_url: Optional[str] = "/openapi.json", url_map: Optional[Map] = None, + filter_unhandled_paths: bool = True, ) -> None: - self.app = app - self.url_map = url_map or self.get_url_map() - if self.url_map is None: + url_map = url_map or _get_url_map(app) + if url_map is None: raise ValueError( "Could not extract url_map from app. Please provide it as an argument to ApitallyMiddleware." ) + self.app = app + self.url_map = url_map self.filter_unhandled_paths = filter_unhandled_paths - validate_client_params(client_id=client_id, env=env, app_version=app_version, sync_interval=sync_interval) self.client = ApitallyClient(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) - self.client.send_app_info(app_info=_get_app_info(self.app, self.url_map, app_version, openapi_url)) self.client.start_sync_loop() + # Get and send app info after a short delay to allow app routes to be registered first + timer = Timer(0.5, self.delayed_send_app_info, kwargs={"app_version": app_version, "openapi_url": openapi_url}) + timer.start() + + def delayed_send_app_info(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None: + app_info = _get_app_info(self.app, self.url_map, app_version, openapi_url) + self.client.send_app_info(app_info=app_info) + def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: status_code = 200 @@ -70,16 +77,7 @@ def log_request(self, environ: WSGIEnvironment, status_code: int, response_time: response_time=response_time, ) - def get_url_map(self) -> Optional[Map]: - if hasattr(self.app, "url_map"): - return self.app.url_map - elif hasattr(self.app, "__self__") and hasattr(self.app.__self__, "url_map"): - return self.app.__self__.url_map - return None - def get_path_template(self, environ: WSGIEnvironment) -> Tuple[str, bool]: - if self.url_map is None: - return environ["PATH_INFO"], False # pragma: no cover url_adapter = self.url_map.bind_to_environ(environ) try: endpoint, _ = url_adapter.match() @@ -105,6 +103,14 @@ def _get_app_info( return app_info +def _get_url_map(app: WSGIApplication) -> Optional[Map]: + if hasattr(app, "url_map"): + return app.url_map + elif hasattr(app, "__self__") and hasattr(app.__self__, "url_map"): + return app.__self__.url_map + return None + + def _get_endpoint_info(url_map: Map) -> List[Dict[str, str]]: return [ {"path": rule.rule, "method": method} diff --git a/apitally/starlette.py b/apitally/starlette.py index 3b03300..99b7895 100644 --- a/apitally/starlette.py +++ b/apitally/starlette.py @@ -24,7 +24,6 @@ import apitally from apitally.client.asyncio import ApitallyClient from apitally.client.base import KeyInfo -from apitally.client.utils import validate_client_params if TYPE_CHECKING: @@ -45,11 +44,10 @@ def __init__( app_version: Optional[str] = None, enable_keys: bool = False, sync_interval: float = 60, - filter_unhandled_paths: bool = True, openapi_url: Optional[str] = "/openapi.json", + filter_unhandled_paths: bool = True, ) -> None: self.filter_unhandled_paths = filter_unhandled_paths - validate_client_params(client_id=client_id, env=env, app_version=app_version, sync_interval=sync_interval) self.client = ApitallyClient(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) self.client.send_app_info(app_info=_get_app_info(app, app_version, openapi_url)) self.client.start_sync_loop() diff --git a/tests/test_flask.py b/tests/test_flask.py index 27caadc..854cd2f 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -27,8 +27,10 @@ def app(module_mocker: MockerFixture) -> Flask: module_mocker.patch("apitally.client.threading.ApitallyClient._instance", None) module_mocker.patch("apitally.client.threading.ApitallyClient.start_sync_loop") module_mocker.patch("apitally.client.threading.ApitallyClient.send_app_info") + module_mocker.patch("apitally.flask.ApitallyMiddleware.delayed_send_app_info") app = Flask("test") + app.wsgi_app = ApitallyMiddleware(app.wsgi_app, client_id=CLIENT_ID, env=ENV) # type: ignore[method-assign] @app.route("/foo//") def foo_bar(bar: int): @@ -42,7 +44,6 @@ def bar(): def baz(): raise ValueError("baz") - app.wsgi_app = ApitallyMiddleware(app.wsgi_app, client_id=CLIENT_ID, env=ENV) # type: ignore[method-assign] return app @@ -87,3 +88,12 @@ def test_middleware_requests_unhandled(app: Flask, mocker: MockerFixture): response = client.post("/xxx/") assert response.status_code == 404 mock.assert_not_called() + + +def test_get_app_info(app: Flask): + from apitally.flask import _get_app_info + + app_info = _get_app_info(app.wsgi_app, app.url_map, app_version="1.2.3", openapi_url="/openapi.json") + assert len(app_info["paths"]) == 3 + assert len(app_info["versions"]) > 1 + app_info["versions"]["app"] == "1.2.3" diff --git a/tests/test_starlette.py b/tests/test_starlette.py index 1963cf2..576aa15 100644 --- a/tests/test_starlette.py +++ b/tests/test_starlette.py @@ -144,21 +144,6 @@ def baz(): return app -def test_middleware_param_validation(app: Starlette): - from apitally.starlette import ApitallyClient, ApitallyMiddleware - - ApitallyClient._instance = None - - with pytest.raises(ValueError): - ApitallyMiddleware(app, client_id="76b5zb91-a0a4-4ea0-a894-57d2b9fcb2c9") - with pytest.raises(ValueError): - ApitallyMiddleware(app, client_id=CLIENT_ID, env="invalid.string") - with pytest.raises(ValueError): - ApitallyMiddleware(app, client_id=CLIENT_ID, app_version="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") - with pytest.raises(ValueError): - ApitallyMiddleware(app, client_id=CLIENT_ID, sync_interval=1) - - def test_middleware_requests_ok(app: Starlette, mocker: MockerFixture): from starlette.testclient import TestClient From 0a1eea7170f8f51c3f7647896ff0b3aa8079e9d6 Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Thu, 17 Aug 2023 14:20:49 +1000 Subject: [PATCH 4/7] Remove notebook --- .gitignore | 2 + notebooks/flask.ipynb | 145 ------------------------------------------ 2 files changed, 2 insertions(+), 145 deletions(-) delete mode 100644 notebooks/flask.ipynb diff --git a/.gitignore b/.gitignore index 0ca7b41..0eb3dab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +notebooks/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/notebooks/flask.ipynb b/notebooks/flask.ipynb deleted file mode 100644 index 28b6f75..0000000 --- a/notebooks/flask.ipynb +++ /dev/null @@ -1,145 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[MIDDLEWARE] GET /hello/ 200 0.00013494491577148438\n", - "200\n", - "b'Hello, world!'\n" - ] - } - ], - "source": [ - "from flask import Flask\n", - "from apitally.flask import ApitallyMiddleware\n", - "\n", - "app = Flask(\"Test App\")\n", - "app.wsgi_app = ApitallyMiddleware(app.wsgi_app)\n", - "\n", - "\n", - "@app.route(\"/hello/\")\n", - "def hello(name):\n", - " return f\"Hello, {name}!\"\n", - "\n", - "\n", - "client = app.test_client()\n", - "response = client.get(\"/hello/world\")\n", - "\n", - "print(response.status_code)\n", - "print(response.data)" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [], - "source": [ - "rule = list(app.url_map.iter_rules())[0]" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'/static/'" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "rule.rule" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'paths': [{'path': '/hello/', 'method': 'GET'}],\n", - " 'versions': {'python': '3.11.4', 'apitally': '0.0.0', 'flask': '2.3.2'},\n", - " 'client': 'apitally-python'}" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from apitally.flask import _get_app_info\n", - "\n", - "_get_app_info(app.wsgi_app, app.url_map, None, None)" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[MIDDLEWARE] GET /hello/ 200 0.00013375282287597656\n" - ] - }, - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from werkzeug.test import Client\n", - "\n", - "client = Client(app.wsgi_app)\n", - "response = client.get(\"/hello/simon\")\n", - "response.status_code == 200" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.4" - }, - "orig_nbformat": 4 - }, - "nbformat": 4, - "nbformat_minor": 2 -} From d2ac5d32e57ba1eeb2897f684fa9dff00d2c1d0e Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Thu, 17 Aug 2023 22:58:34 +1000 Subject: [PATCH 5/7] Add auth for Flask --- apitally/client/asyncio.py | 12 +++++-- apitally/client/threading.py | 9 +++-- apitally/flask.py | 30 +++++++++++++++- tests/conftest.py | 24 ++++++++++++- tests/test_client_asyncio.py | 1 + tests/test_flask.py | 68 ++++++++++++++++++++++++++++++++++++ tests/test_starlette.py | 15 ++------ 7 files changed, 140 insertions(+), 19 deletions(-) diff --git a/apitally/client/asyncio.py b/apitally/client/asyncio.py index 6ccba72..c109963 100644 --- a/apitally/client/asyncio.py +++ b/apitally/client/asyncio.py @@ -2,7 +2,8 @@ import asyncio import logging -from typing import Any, Dict +import sys +from typing import Any, Dict, Optional import backoff import httpx @@ -24,13 +25,14 @@ class ApitallyClient(ApitallyClientBase): def __init__(self, client_id: str, env: str, enable_keys: bool = False, sync_interval: float = 60) -> None: super().__init__(client_id=client_id, env=env, enable_keys=enable_keys, sync_interval=sync_interval) self._stop_sync_loop = False + self._sync_loop_task: Optional[asyncio.Task[Any]] = None def get_http_client(self) -> httpx.AsyncClient: return httpx.AsyncClient(base_url=self.hub_url, timeout=1) def start_sync_loop(self) -> None: self._stop_sync_loop = False - asyncio.create_task(self._run_sync_loop()) + self._sync_loop_task = asyncio.create_task(self._run_sync_loop()) async def _run_sync_loop(self) -> None: if self.enable_keys: @@ -61,8 +63,12 @@ async def send_requests_data(self, client: httpx.AsyncClient) -> None: await self._send_requests_data(client, payload) async def get_keys(self, client: httpx.AsyncClient) -> None: - if response_data := await self._get_keys(client): + if response_data := await self._get_keys(client): # Response data can be None if backoff gives up self.handle_keys_response(response_data) + elif self.key_registry.salt is None: + logger.error("Initial Apitally key sync failed") + # Exit because the application will not be able to authenticate requests + sys.exit(1) @retry async def _send_app_info(self, payload: Dict[str, Any]) -> None: diff --git a/apitally/client/threading.py b/apitally/client/threading.py index 9dd4091..cd7c6d6 100644 --- a/apitally/client/threading.py +++ b/apitally/client/threading.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys import time from threading import Event, Thread from typing import Any, Callable, Dict, Optional @@ -79,8 +80,12 @@ def send_requests_data(self, session: requests.Session) -> None: self._send_requests_data(session, payload) def get_keys(self, session: requests.Session) -> None: - response_data = self._get_keys(session) - self.handle_keys_response(response_data) + if response_data := self._get_keys(session): # Response data can be None if backoff gives up + self.handle_keys_response(response_data) + elif self.key_registry.salt is None: + logger.error("Initial Apitally key sync failed") + # Exit because the application will not be able to authenticate requests + sys.exit(1) @retry def _send_app_info(self, payload: Dict[str, Any]) -> None: diff --git a/apitally/flask.py b/apitally/flask.py index d9a0e5f..1808732 100644 --- a/apitally/flask.py +++ b/apitally/flask.py @@ -2,10 +2,12 @@ import sys import time +from functools import wraps from threading import Timer from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple import flask +from flask import g, make_response, request from werkzeug.exceptions import NotFound from werkzeug.test import Client @@ -18,6 +20,9 @@ from werkzeug.routing.map import Map +__all__ = ["ApitallyMiddleware", "require_api_key"] + + class ApitallyMiddleware: def __init__( self, @@ -32,7 +37,7 @@ def __init__( filter_unhandled_paths: bool = True, ) -> None: url_map = url_map or _get_url_map(app) - if url_map is None: + if url_map is None: # pragma: no cover raise ValueError( "Could not extract url_map from app. Please provide it as an argument to ApitallyMiddleware." ) @@ -87,6 +92,29 @@ def get_path_template(self, environ: WSGIEnvironment) -> Tuple[str, bool]: return environ["PATH_INFO"], False +def require_api_key(func=None, *, scopes: Optional[List[str]] = None): + def decorator(func): + @wraps(func) + def wrapped_func(*args, **kwargs): + authorization = request.headers.get("Authorization") + if authorization is None: + return make_response("Not authenticated", 401, {"WWW-Authenticate": "ApiKey"}) + scheme, _, param = authorization.partition(" ") + if scheme.lower() != "apikey": + return make_response("Unsupported authentication scheme", 401, {"WWW-Authenticate": "ApiKey"}) + key_info = ApitallyClient.get_instance().key_registry.get(param) + if key_info is None: + return make_response("Invalid API key", 403) + if scopes is not None and not key_info.check_scopes(scopes): + return make_response("Permission denied", 403) + g.key_info = key_info + return func(*args, **kwargs) + + return wrapped_func + + return decorator if func is None else decorator(func) + + def _get_app_info( app: WSGIApplication, url_map: Map, diff --git a/tests/conftest.py b/tests/conftest.py index 49a59f1..3b66593 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,17 @@ +from __future__ import annotations + import asyncio import os from asyncio import AbstractEventLoop -from typing import Iterator +from typing import TYPE_CHECKING, Iterator import pytest +if TYPE_CHECKING: + from apitally.client.base import KeyRegistry + + if os.getenv("PYTEST_RAISE", "0") != "0": @pytest.hookimpl(tryfirst=True) @@ -23,3 +29,19 @@ def event_loop() -> Iterator[AbstractEventLoop]: loop = policy.new_event_loop() yield loop loop.close() + + +@pytest.fixture(scope="module") +def key_registry() -> KeyRegistry: + from apitally.client.base import KeyInfo, KeyRegistry + + key_registry = KeyRegistry() + key_registry.salt = "54fd2b80dbfeb87d924affbc91b77c76" + key_registry.keys = { + "bcf46e16814691991c8ed756a7ca3f9cef5644d4f55cd5aaaa5ab4ab4f809208": KeyInfo( + key_id=1, + name="Test key", + scopes=["foo"], + ) + } + return key_registry diff --git a/tests/test_client_asyncio.py b/tests/test_client_asyncio.py index 4e6810b..493f10b 100644 --- a/tests/test_client_asyncio.py +++ b/tests/test_client_asyncio.py @@ -47,6 +47,7 @@ async def test_sync_loop(client: ApitallyClient, mocker: MockerFixture): client.stop_sync_loop() # Should stop after first iteration assert send_requests_data_mock.await_count >= 1 assert get_keys_mock.await_count >= 2 + await asyncio.sleep(0.05) # Wait for task to finish async def test_send_requests_data(client: ApitallyClient, httpx_mock: HTTPXMock): diff --git a/tests/test_flask.py b/tests/test_flask.py index 854cd2f..4dff852 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from flask import Flask + from apitally.client.base import KeyRegistry + CLIENT_ID = "76b5cb91-a0a4-4ea0-a894-57d2b9fcb2c9" ENV = "default" @@ -47,6 +49,38 @@ def baz(): return app +@pytest.fixture(scope="module") +def app_with_auth(module_mocker: MockerFixture) -> Flask: + from flask import Flask + + from apitally.flask import ApitallyMiddleware, require_api_key + + module_mocker.patch("apitally.client.threading.ApitallyClient._instance", None) + module_mocker.patch("apitally.client.threading.ApitallyClient.start_sync_loop") + module_mocker.patch("apitally.client.threading.ApitallyClient.send_app_info") + module_mocker.patch("apitally.flask.ApitallyMiddleware.delayed_send_app_info") + + app = Flask("test") + app.wsgi_app = ApitallyMiddleware(app.wsgi_app, client_id=CLIENT_ID, env=ENV) # type: ignore[method-assign] + + @app.route("/foo/") + @require_api_key(scopes=["foo"]) + def foo(): + return "foo" + + @app.route("/bar/") + @require_api_key(scopes=["bar"]) + def bar(): + return "bar" + + @app.route("/baz/") + @require_api_key + def baz(): + return "baz" + + return app + + def test_middleware_requests_ok(app: Flask, mocker: MockerFixture): mock = mocker.patch("apitally.client.base.RequestLogger.log_request") client = app.test_client() @@ -90,6 +124,40 @@ def test_middleware_requests_unhandled(app: Flask, mocker: MockerFixture): mock.assert_not_called() +def test_require_api_key(app_with_auth: Flask, key_registry: KeyRegistry, mocker: MockerFixture): + client = app_with_auth.test_client() + headers = {"Authorization": "ApiKey 7ll40FB.DuHxzQQuGQU4xgvYvTpmnii7K365j9VI"} + mock = mocker.patch("apitally.flask.ApitallyClient.get_instance") + mock.return_value.key_registry = key_registry + + # Unauthenticated + response = client.get("/foo/") + assert response.status_code == 401 + + response = client.get("/baz/") + assert response.status_code == 401 + + # Invalid auth scheme + response = client.get("/foo/", headers={"Authorization": "Bearer something"}) + assert response.status_code == 401 + + # Invalid API key + response = client.get("/foo/", headers={"Authorization": "ApiKey invalid"}) + assert response.status_code == 403 + + # Valid API key with required scope + response = client.get("/foo/", headers=headers) + assert response.status_code == 200 + + # Valid API key, no scope required + response = client.get("/baz/", headers=headers) + assert response.status_code == 200 + + # Valid API key without required scope + response = client.get("/bar/", headers=headers) + assert response.status_code == 403 + + def test_get_app_info(app: Flask): from apitally.flask import _get_app_info diff --git a/tests/test_starlette.py b/tests/test_starlette.py index 576aa15..933d72e 100644 --- a/tests/test_starlette.py +++ b/tests/test_starlette.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: from starlette.applications import Starlette + from apitally.client.base import KeyRegistry + from starlette.background import BackgroundTasks # import here to avoid pydantic error @@ -202,21 +204,10 @@ def test_middleware_requests_unhandled(app: Starlette, mocker: MockerFixture): mock.assert_not_called() -def test_keys_auth_backend(app_with_auth: Starlette, mocker: MockerFixture): +def test_keys_auth_backend(app_with_auth: Starlette, key_registry: KeyRegistry, mocker: MockerFixture): from starlette.testclient import TestClient - from apitally.client.base import KeyInfo, KeyRegistry - client = TestClient(app_with_auth) - key_registry = KeyRegistry() - key_registry.salt = "54fd2b80dbfeb87d924affbc91b77c76" - key_registry.keys = { - "bcf46e16814691991c8ed756a7ca3f9cef5644d4f55cd5aaaa5ab4ab4f809208": KeyInfo( - key_id=1, - name="Test key", - scopes=["foo"], - ) - } headers = {"Authorization": "ApiKey 7ll40FB.DuHxzQQuGQU4xgvYvTpmnii7K365j9VI"} mock = mocker.patch("apitally.starlette.ApitallyClient.get_instance") mock.return_value.key_registry = key_registry From 1fa293d8b51e16281853068a8b6b6bc7f18d8fbf Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Thu, 17 Aug 2023 23:04:31 +1000 Subject: [PATCH 6/7] Fix --- .github/workflows/tests.yaml | 2 +- tests/test_client_asyncio.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4292e5a..c355838 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,7 +36,7 @@ jobs: - name: Run Poetry check run: poetry check - name: Install dependencies - run: poetry install --no-interaction --with dev + run: poetry install --no-interaction --all-extras - name: Run checks run: poetry run make check - name: Run tests and create coverage report diff --git a/tests/test_client_asyncio.py b/tests/test_client_asyncio.py index 493f10b..92e4a93 100644 --- a/tests/test_client_asyncio.py +++ b/tests/test_client_asyncio.py @@ -43,11 +43,11 @@ async def test_sync_loop(client: ApitallyClient, mocker: MockerFixture): mocker.patch.object(client, "sync_interval", 0.05) client.start_sync_loop() - await asyncio.sleep(0.09) # Ensure loop enters first iteration - client.stop_sync_loop() # Should stop after first iteration + await asyncio.sleep(0.2) # Ensure loop starts + client.stop_sync_loop() # Should stop after next iteration + await asyncio.sleep(0.1) # Wait for task to finish assert send_requests_data_mock.await_count >= 1 assert get_keys_mock.await_count >= 2 - await asyncio.sleep(0.05) # Wait for task to finish async def test_send_requests_data(client: ApitallyClient, httpx_mock: HTTPXMock): From 39ef3399892b685012be0fa14accbd4f33bd21ef Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Thu, 17 Aug 2023 23:07:50 +1000 Subject: [PATCH 7/7] Add flask to test matrix --- .github/workflows/tests.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c355838..1c24a55 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -59,6 +59,11 @@ jobs: - fastapi==0.99.1 starlette - fastapi==0.90.1 starlette - fastapi==0.87.0 starlette + - flask + - flask==2.3.2 + - flask==2.2.5 + - flask==2.1.3 + - flask==2.0.3 steps: - uses: actions/checkout@v3 - name: Load cached Poetry installation