From ba25664e65a281d98ac638243295a2427b602771 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:11:07 -0800 Subject: [PATCH 01/40] add a websockets router --- openbb_platform/dev_install.py | 1 + .../extensions/websockets/README.md | 0 .../websockets/openbb_websockets/__init__.py | 1 + .../websockets/openbb_websockets/broadcast.py | 233 +++ .../websockets/openbb_websockets/client.py | 675 +++++++ .../websockets/openbb_websockets/helpers.py | 238 +++ .../websockets/openbb_websockets/listen.py | 157 ++ .../websockets/openbb_websockets/models.py | 98 + .../openbb_websockets/websockets_router.py | 428 +++++ .../extensions/websockets/poetry.lock | 1618 +++++++++++++++++ .../extensions/websockets/pyproject.toml | 19 + .../providers/fmp/openbb_fmp/__init__.py | 2 + .../openbb_fmp/models/websocket_connection.py | 198 ++ .../fmp/openbb_fmp/utils/websocket_client.py | 190 ++ .../tiingo/openbb_tiingo/__init__.py | 2 + .../models/websocket_connection.py | 288 +++ .../openbb_tiingo/utils/websocket_client.py | 278 +++ openbb_platform/pyproject.toml | 3 + 18 files changed, 4429 insertions(+) create mode 100644 openbb_platform/extensions/websockets/README.md create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/__init__.py create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/broadcast.py create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/client.py create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/helpers.py create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/listen.py create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/models.py create mode 100644 openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py create mode 100644 openbb_platform/extensions/websockets/poetry.lock create mode 100644 openbb_platform/extensions/websockets/pyproject.toml create mode 100644 openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py create mode 100644 openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py create mode 100644 openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py create mode 100644 openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py diff --git a/openbb_platform/dev_install.py b/openbb_platform/dev_install.py index 258d41fa9bd3..d3f540c6b26e 100644 --- a/openbb_platform/dev_install.py +++ b/openbb_platform/dev_install.py @@ -71,6 +71,7 @@ openbb-econometrics = { path = "./extensions/econometrics", optional = true, develop = true } openbb-quantitative = { path = "./extensions/quantitative", optional = true, develop = true } openbb-technical = { path = "./extensions/technical", optional = true, develop = true } +openbb-websockets = { path = "./extensions/websockets", optional = true, develop = true } """ diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openbb_platform/extensions/websockets/openbb_websockets/__init__.py b/openbb_platform/extensions/websockets/openbb_websockets/__init__.py new file mode 100644 index 000000000000..50da7c837624 --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/__init__.py @@ -0,0 +1 @@ +"""OpenBB WebSockets Router Extension.""" diff --git a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py new file mode 100644 index 000000000000..9235a6b4bdf1 --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py @@ -0,0 +1,233 @@ +import asyncio +import json +import sys +from typing import Optional + +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from starlette.websockets import WebSocketState + +from openbb_websockets.helpers import get_logger, parse_kwargs + +connected_clients = set() + +kwargs = parse_kwargs() + +HOST = kwargs.pop("host", None) or "localhost" +PORT = kwargs.pop("port", None) or 6666 +PORT = int(PORT) + +RESULTS_FILE = kwargs.pop("results_file", None) +TABLE_NAME = kwargs.pop("table_name", None) or "records" +SLEEP_TIME = kwargs.pop("sleep_time", None) or 0.25 +AUTH_TOKEN = kwargs.pop("auth_token", None) + +app = FastAPI() + + +@app.websocket("/") +async def websocket_endpoint( # noqa: PLR0915 + websocket: WebSocket, auth_token: Optional[str] = None +): + + broadcast_server = BroadcastServer( + RESULTS_FILE, + TABLE_NAME, + SLEEP_TIME, + str(AUTH_TOKEN), + ) + auth_token = str(auth_token) + + if ( + broadcast_server.auth_token is not None + and auth_token != broadcast_server.auth_token + ): + await websocket.accept() + await websocket.send_text( + "ERROR: Invalid authentication token. Could not connect to the broadcast." + ) + broadcast_server.logger.error( + "ERROR: Invalid authentication token passed by a client connecting." + ) + await websocket.close(code=1008, reason="Invalid authentication token") + return + + await websocket.accept() + + if RESULTS_FILE is None: + raise ValueError("Results file path is required for WebSocket server.") + + broadcast_server.websocket = websocket + connected_clients.add(broadcast_server) + + stream_task = asyncio.create_task(broadcast_server.stream_results()) + try: + await websocket.receive_text() + + except WebSocketDisconnect: + pass + except Exception as e: + broadcast_server.logger.error(f"Unexpected error: {e}") + pass + finally: + if broadcast_server in connected_clients: + connected_clients.remove(broadcast_server) + stream_task.cancel() + try: + await stream_task + except asyncio.CancelledError: + broadcast_server.logger.info("Stream task cancelled") + except Exception as e: + broadcast_server.logger.error(f"Error while cancelling stream task: {e}") + if websocket.client_state != WebSocketState.DISCONNECTED: + try: + await websocket.close() + except RuntimeError as e: + broadcast_server.logger.error(f"Error while closing websocket: {e}") + + +class BroadcastServer: + """Stream new results from a continuously written SQLite database. + + Not intended to be used directly, it is initialized by the server app when it accepts a new connection. + It is responsible for reading the results database and sending new messages to the connected client(s). + """ + + def __init__( + self, + results_file, + table_name, + sleep_time: float = 0.25, + auth_token: Optional[str] = None, + ): + + self.results_file = results_file + self.table_name = table_name + self.logger = get_logger("openbb.websocket.broadcast_server") + self.sleep_time = sleep_time + self.auth_token = auth_token + self._app = app + self.websocket = None + + async def stream_results(self): # noqa: PLR0915 + """Continuously read the database and send new messages as JSON via WebSocket.""" + import sqlite3 # noqa + from pathlib import Path + from openbb_core.app.model.abstract.error import OpenBBError + + file_path = Path(self.results_file).absolute() + last_id = 0 + + if not file_path.exists(): + self.logger.error(f"Results file not found: {file_path}") + return + else: + conn = sqlite3.connect(self.results_file) + cursor = conn.cursor() + cursor.execute(f"SELECT MAX(id) FROM {self.table_name}") # noqa:S608 + last_id = cursor.fetchone()[0] or 0 + conn.close() + + try: + while True: + try: + if file_path.exists(): + conn = sqlite3.connect(self.results_file) + cursor = conn.cursor() + cursor.execute( + f"SELECT * FROM {self.table_name} WHERE id > ?", # noqa:S608 + (last_id,), + ) + rows = cursor.fetchall() + conn.close() + + if rows: + for row in rows: + index, message = row + await self.broadcast(json.dumps(json.loads(message))) + last_id = max(row[0] for row in rows) + else: + self.logger.error(f"Results file not found: {file_path}") + break + + await asyncio.sleep(self.sleep_time) + except KeyboardInterrupt: + self.logger.info("\nResults stream cancelled.") + break + except sqlite3.OperationalError as e: + if "no such table" in str(e): + self.logger.error( + "Results file was removed by the parent process." + ) + break + else: + raise OpenBBError(e) from e + except asyncio.CancelledError: + break + except WebSocketDisconnect: + pass + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + finally: + return + + async def broadcast(self, message: str): + """Broadcast a message to all connected connected clients.""" + disconnected_clients = set() + for client in connected_clients.copy(): + try: + await client.websocket.send_text(message) + except WebSocketDisconnect: + disconnected_clients.add(client) + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + disconnected_clients.add(client) + # Remove disconnected connected clients + for client in disconnected_clients: + connected_clients.remove(client) + + def start_app(self, host: str = "127.0.0.1", port: int = 6666, **kwargs): + uvicorn.run( + self._app, + host=host, + port=port, + **kwargs, + ) + + +def create_broadcast_server( + results_file: str, + table_name: str, + sleep_time: float = 0.25, + auth_token: Optional[str] = None, + **kwargs, +): + return BroadcastServer(results_file, table_name, sleep_time, auth_token) + + +def main(): + broadcast_server = create_broadcast_server( + RESULTS_FILE, + TABLE_NAME, + SLEEP_TIME, + str(AUTH_TOKEN), + ) + + try: + broadcast_server.start_app( + host=HOST, + port=PORT, + **kwargs, + ) + except TypeError as e: + broadcast_server.logger.error( + f"Invalid keyword argument passed to unvicorn. -> {e.args[0]}\n" + ) + except KeyboardInterrupt: + broadcast_server.logger.info("Broadcast server terminated.") + finally: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py new file mode 100644 index 000000000000..3ddad5092e4c --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -0,0 +1,675 @@ +"""WebSocket Client module for interacting with a provider websocket in a non-blocking pattern.""" + +# pylint: disable=too-many-statements +# flake8: noqa: PLR0915 +import logging +from typing import TYPE_CHECKING, Literal, Optional + +if TYPE_CHECKING: + from openbb_core.provider.abstract.data import Data + + +class WebSocketClient: + """Client for interacting with a websocket server in a non-blocking pattern. + + Parameters + ---------- + name : str + Name to assign the WebSocket connection. Used to identify and manage multiple instances. + module : str + The Python module for the provider server connection. Runs in a separate thread. + Example: 'openbb_fmp.websockets.server'. Pass additional keyword arguments by including kwargs. + symbol : Optional[str] + The symbol(s) requested to subscribe. Enter multiple symbols separated by commas without spaces. + limit : Optional[int] + The limit of records to hold in memory. Once the limit is reached, the oldest records are removed. + Default is None. + results_file : Optional[str] + Absolute path to the file for continuous writing. By default, a temporary file is created. + table_name : Optional[str] + SQL table name to store serialized data messages. By default, 'records'. + save_results : bool + Whether to persist the results after the main Python session ends. Default is False. + data_model : Optional[Data] + Pydantic data model to validate the results before storing them in the database. + Also used to deserialize the results from the database. + auth_token : Optional[str] + The authentication token to use for the WebSocket connection. Default is None. + Only used for API and Python application endpoints. + logger : Optional[logging.Logger] + The logger instance to use this connection. By default, a new logger is created. + kwargs : dict + Additional keyword arguments to pass to the target module. + + Properties + ---------- + symbol : str + Symbol(s) requested to subscribe. + module : str + Path to the provider connection script. + is_running : bool + Check if the provider connection is running. + is_broadcasting : bool + Check if the broadcast server is running. + broadcast_address : str + URI address for the results broadcast server. + results : list + All stored results from the provider's WebSocket stream. The results are stored in a SQLite database. + Set the 'limit' property to cap the number of stored records. + Clear the results by deleting the property. e.g., del client.results + transformed_results : list + Deserialize the records from the results file using the provided data model, if available. + + Methods + ------- + connect + Connect to the provider WebSocket stream. + disconnect + Disconnect from the provider WebSocket. + subscribe + Subscribe to a new symbol or list of symbols. + unsubscribe + Unsubscribe from a symbol or list of symbols. + start_broadcasting + Start the broadcast server to stream results over a network connection. + stop_broadcasting + Stop the broadcast server and disconnect all reading clients. + send_message + Send a message to the WebSocket process. + """ + + def __init__( # noqa: PLR0913 + self, + name: str, + module: str, + symbol: Optional[str] = None, + limit: Optional[int] = None, + results_file: Optional[str] = None, + table_name: Optional[str] = None, + save_results: bool = False, + data_model: Optional["Data"] = None, + auth_token: Optional[str] = None, + logger: Optional[logging.Logger] = None, + **kwargs, + ): + """Initialize the WebSocketClient class.""" + # pylint: disable=import-outside-toplevel + import asyncio # noqa + import atexit + import tempfile + import threading + from aiosqlite import DatabaseError + from queue import Queue + from pathlib import Path + from openbb_websockets.helpers import get_logger + + self.name = name + self.module = module.replace(".py", "") + self.results_file = results_file if results_file else None + self.table_name = table_name if table_name else "records" + self._limit = limit + self.data_model = data_model + self._auth_token = auth_token + self._symbol = symbol + self._kwargs = ( + [f"{k}={str(v).strip().replace(" ", "_")}" for k, v in kwargs.items()] + if kwargs + else None + ) + + self._process = None + self._psutil_process = None + self._thread = None + self._log_thread = None + self._provider_message_queue = Queue() + self._stop_log_thread_event = threading.Event() + self._stop_broadcasting_event = threading.Event() + self._broadcast_address = None + self._broadcast_process = None + self._psutil_broadcast_process = None + self._broadcast_thread = None + self._broadcast_log_thread = None + self._broadcast_message_queue = Queue() + + if not results_file: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + pass + temp_file_path = temp_file.name + self.results_path = Path(temp_file_path).absolute() + self.results_file = temp_file_path + + self.results_path = Path(self.results_file).absolute() + self.save_results = save_results + self.logger = logger if logger else get_logger("openbb.websocket.client") + + atexit.register(self._atexit) + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + try: + if loop.is_running(): + loop.create_task(self._setup_database()) + else: + asyncio.run(self._setup_database()) + except DatabaseError as e: + self.logger.error("Error setting up the SQLite database and table: %s", e) + + def _atexit(self): + """Clean up the WebSocket client processes at exit.""" + # pylint: disable=import-outside-toplevel + import os + + if self.is_running: + self.disconnect() + if self.is_broadcasting: + self.stop_broadcasting() + if self.save_results: + self.logger.info("Websocket results saved to, %s\n", self.results_file) + if os.path.exists(self.results_file): + os.remove(self.results_file) + + async def _setup_database(self): + """Set up the SQLite database and table.""" + # pylint: disable=import-outside-toplevel + from openbb_websockets.helpers import setup_database + + return await setup_database(self.results_path, self.table_name) + + def _log_provider_output(self, output_queue): + """Log output from the provider server queue.""" + # pylint: disable=import-outside-toplevel + import queue # noqa + import sys + from openbb_websockets.helpers import clean_message + + while not self._stop_log_thread_event.is_set(): + try: + output = output_queue.get(timeout=1) + if output: + output = clean_message(output) + output = output + "\n" + sys.stdout.write(output + "\n") + sys.stdout.flush() + except queue.Empty: + continue + + def _log_broadcast_output(self, output_queue): + """Log output from the broadcast server queue.""" + # pylint: disable=import-outside-toplevel + import queue # noqa + import sys + from openbb_websockets.helpers import clean_message + + while not self._stop_broadcasting_event.is_set(): + try: + output = output_queue.get(timeout=1) + + if output and "Uvicorn running" in output: + address = ( + output.split("Uvicorn running on ")[-1] + .strip() + .replace(" (Press CTRL+C to quit)", "") + .replace("http", "ws") + ) + output = "INFO: " + f"Stream results from {address}" + self._broadcast_address = address + + if output and "Started server process" in output: + output = None + + if output and "Waiting for application startup." in output: + output = None + + if output and "Application startup complete." in output: + output = None + + if output: + if "ERROR:" in output: + output = output.replace("ERROR:", "BROADCAST ERROR:") + "\n" + if "INFO:" in output: + output = output.replace("INFO:", "BROADCAST INFO:") + "\n" + output = output[0] if isinstance(output, tuple) else output + output = clean_message(output) + sys.stdout.write(output + "\n") + sys.stdout.flush() + except queue.Empty: + continue + + def connect(self): + """Connect to the provider WebSocket.""" + # pylint: disable=import-outside-toplevel + import json # noqa + import os + import queue + import subprocess + import threading + import psutil + + if self.is_running: + self.logger.info("Provider connection already running.") + return + + symbol = self.symbol + + if not symbol: + self.logger.info("No subscribed symbols.") + return + + command = self.module.copy() + command.extend([f"symbol={symbol}"]) + command.extend([f"results_file={self.results_file}"]) + command.extend([f"table_name={self.table_name}"]), + + if self.limit: + command.extend([f"limit={self.limit}"]) + + if self._kwargs: + for kwarg in self._kwargs: + if kwarg not in command: + command.extend([kwarg]) + + self._process = subprocess.Popen( # noqa + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + env=os.environ, + text=True, + bufsize=1, + ) + self._psutil_process = psutil.Process(self._process.pid) + + log_output_queue = queue.Queue() + self._thread = threading.Thread( + target=non_blocking_websocket, + args=( + self, + log_output_queue, + self._provider_message_queue, + ), + ) + self._thread.daemon = True + self._thread.start() + + self._log_thread = threading.Thread( + target=self._log_provider_output, + args=(log_output_queue,), + ) + self._log_thread.daemon = True + self._log_thread.start() + + if not self.is_running: + self.logger.error("The provider server failed to start.") + + def send_message( + self, message, target: Literal["provider", "broadcast"] = "provider" + ): + """Send a message to the WebSocket process.""" + if target == "provider": + self._provider_message_queue.put(message) + read_message_queue(self, self._provider_message_queue) + elif target == "broadcast": + self._broadcast_message_queue.put(message) + read_message_queue(self, self._broadcast_message_queue, target="broadcast") + + def disconnect(self): + """Disconnect from the provider WebSocket.""" + self._stop_log_thread_event.set() + if self._process is None or self.is_running is False: + self.logger.info("Not connected to the provider WebSocket.") + return + if ( + self._psutil_process is not None + and hasattr(self._psutil_process, "is_running") + and self._psutil_process.is_running() + ): + self._psutil_process.kill() + self._process.wait() + self._thread.join() + self._log_thread.join() + self._stop_log_thread_event.clear() + self.logger.info("Disconnected from the provider WebSocket.") + return + + def subscribe(self, symbol): + """Subscribe to a new symbol or list of symbols.""" + # pylint: disable=import-outside-toplevel + import json + + ticker = symbol if isinstance(symbol, list) else symbol.split(",") + msg = {"event": "subscribe", "symbol": ticker} + self.send_message(json.dumps(msg)) + old_symbols = self.symbol.split(",") + new_symbols = list(set(old_symbols + ticker)) + self._symbol = ",".join(new_symbols) + + def unsubscribe(self, symbol): + """Unsubscribe from a symbol or list of symbols.""" + # pylint: disable=import-outside-toplevel + import json + + if not self.symbol: + self.logger.info("No subscribed symbols.") + return + + ticker = symbol if isinstance(symbol, list) else symbol.split(",") + msg = {"event": "unsubscribe", "symbol": ticker} + self.send_message(json.dumps(msg)) + old_symbols = self.symbol.split(",") + new_symbols = list(set(old_symbols) - set(ticker)) + self._symbol = ",".join(new_symbols) + + @property + def is_running(self): + """Check if the provider connection is running.""" + if hasattr(self._psutil_process, "is_running"): + return self._psutil_process.is_running() + return False + + @property + def is_broadcasting(self): + """Check if the broadcast server is running.""" + if hasattr(self._psutil_broadcast_process, "is_running"): + return self._psutil_broadcast_process.is_running() + return False + + @property + def results(self): + """Retrieve the raw results dumped by the WebSocket stream.""" + # pylint: disable=import-outside-toplevel + import json # noqa + import sqlite3 + + output: list = [] + file_path = self.results_path + if file_path.exists(): + with sqlite3.connect(file_path) as conn: + cursor = conn.execute(f"SELECT * FROM {self.table_name}") # noqa + for row in cursor: + index, message = row + output.append(json.loads(message)) + + return output + + self.logger.info("No results found in %s", self.results_file) + + return [] + + @results.deleter + def results(self): + """Clear results stored from the WebSocket stream.""" + # pylint: disable=import-outside-toplevel + import asyncio + import sqlite3 + + try: + with sqlite3.connect(self.results_path) as conn: + conn.execute(f"DELETE FROM {self.table_name}") # noqa + conn.commit() + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + loop.run_until_complete(self._setup_database()) + self.logger.info( + "Results cleared from table %s in %s", + self.table_name, + self.results_file, + ) + except Exception as e: + self.logger.error("Error clearing results: %s", e) + + @property + def module(self): + """Path to the provider connection script.""" + return self._module + + @module.setter + def module(self, module): + """Set the path to the provider connection script.""" + # pylint: disable=import-outside-toplevel + import sys + + self._module = [ + sys.executable, + "-m", + module, + ] + + @property + def symbol(self): + """Symbol(s) requested to subscribe.""" + return self._symbol + + @property + def limit(self): + """Get the limit of records to hold in memory.""" + return self._limit + + @limit.setter + def limit(self, limit): + """Set the limit of records to hold in memory.""" + self._limit = limit + + @property + def broadcast_address(self): + """Get the WebSocket broadcast address.""" + return ( + self._broadcast_address + if self._broadcast_address and self.is_broadcasting + else None + ) + + def start_broadcasting( + self, + host: str = "127.0.0.1", + port: int = 6666, + **kwargs, + ): + """Broadcast results over a network connection.""" + # pylint: disable=import-outside-toplevel + import os # noqa + import subprocess + import sys + import threading + import psutil + import queue + from openbb_platform_api.utils.api import check_port + + if ( + self._broadcast_process is not None + and self._broadcast_process.poll() is None + ): + self.logger.info( + f"WebSocket broadcast already running on: {self._broadcast_address}" + ) + return + + open_port = check_port(host, port) + + if open_port != port: + msg = f"Port {port} is already in use. Using {open_port} instead." + self.logger.warning(msg) + + command = [ + sys.executable, + "-m", + "openbb_websockets.broadcast", + f"host={host}", + f"port={open_port}", + f"results_file={self.results_file}", + f"table_name={self.table_name}", + f"auth_token={self._auth_token}", + ] + if kwargs: + for kwarg in kwargs: + command.extend([f"{kwarg}={kwargs[kwarg]}"]) + + self._broadcast_process = subprocess.Popen( # noqa + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + env=os.environ, + text=True, + bufsize=1, + ) + self._psutil_broadcast_process = psutil.Process(self._broadcast_process.pid) + output_queue = queue.Queue() + self._broadcast_thread = threading.Thread( + target=non_blocking_broadcast, + args=( + self, + output_queue, + self._broadcast_message_queue, + ), + ) + self._broadcast_thread.daemon = True + self._broadcast_thread.start() + + self._broadcast_log_thread = threading.Thread( + target=self._log_broadcast_output, + args=(output_queue,), + ) + self._broadcast_log_thread.daemon = True + self._broadcast_log_thread.start() + + if not self.is_broadcasting: + self.logger.error( + "The broadcast server failed to start on: %s", + self._broadcast_address, + ) + + def stop_broadcasting(self): + """Stop the broadcast server.""" + broadcast_address = self._broadcast_address + self._stop_broadcasting_event.set() + if self._broadcast_process is None or self.is_broadcasting is False: + self.logger.info("Not currently broadcasting.") + return + if ( + self._psutil_broadcast_process is not None + and hasattr(self._psutil_broadcast_process, "is_running") + and self._psutil_broadcast_process.is_running() + ): + self._psutil_broadcast_process.kill() + if broadcast_address: + self.logger.info("Stopped broadcasting to: %s", broadcast_address) + + self._broadcast_process.wait() + self._broadcast_thread.join() + self._broadcast_log_thread.join() + self._broadcast_process = None + self._psutil_broadcast_process = None + self._broadcast_address = None + self._stop_broadcasting_event.clear() + return + + @property + def transformed_results(self): + """Deserialize the records from the results file.""" + # pylint: disable=import-outside-toplevel + import json + + if not self.data_model: + raise NotImplementedError("No model provided to transform the results.") + + return [self.data_model.model_validate(json.loads(d)) for d in self.results] + + def __repr__(self): + """Return the WebSocketClient representation.""" + return ( + f"WebSocketClient(module={self.module}, symbol={self.symbol}, " + f"is_running={self.is_running}, provider_pid: " + f"{self._psutil_process.pid if self._psutil_process else ''}, is_broadcasting={self.is_broadcasting}, " + f"broadcast_address={self.broadcast_address}, " + f"broadcast_pid: {self._psutil_broadcast_process.pid if self._psutil_broadcast_process else ''}, " + f"results_file={self.results_file}, table_name={self.table_name}, " + f"save_results={self.save_results})" + ) + + +def non_blocking_websocket(client, output_queue, provider_message_queue): + """Communicate with the threaded process.""" + try: + while not client._stop_log_thread_event.is_set(): + while not provider_message_queue.empty(): + read_message_queue(client, provider_message_queue) + output = client._process.stdout.readline() + if output == "" and client._process.poll() is not None: + break + if output: + output_queue.put(output.strip()) + + except Exception as e: + raise e from e + client.logger.error(f"Error in non_blocking_websocket: {e}") + finally: + client._process.stdout.close() + client._process.wait() + + +def send_message( + client, message, target: Literal["provider", "broadcast"] = "provider" +): + """Send a message to the WebSocket process.""" + try: + if target == "provider": + if client._process and client._process.stdin: + client._process.stdin.write(message + "\n") + client._process.stdin.flush() + else: + client.logger.error("Provider process is not running.") + elif target == "broadcast": + if client._broadcast_process and client._broadcast_process.stdin: + client._broadcast_process.stdin.write(message + "\n") + client._broadcast_process.stdin.flush() + else: + client.logger.error("Broadcast process is not running.") + except Exception as e: + client.logger.error(f"Error sending message to WebSocket process: {e}") + + +def read_message_queue( + client, message_queue, target: Literal["provider", "broadcast"] = "provider" +): + """Read messages from the queue and send them to the WebSocket process.""" + while not message_queue.empty(): + try: + if target == "provider": + while not client._stop_log_thread_event.is_set(): + message = message_queue.get(timeout=1) + if message: + send_message(client, message, target="provider") + elif target == "broadcast": + while not client._stop_broadcasting_event.is_set(): + message = message_queue.get(timeout=1) + if message: + send_message(client, message, target="broadcast") + except Exception as e: + err = f"Error reading message queue: {e.args[0]} -> {message}" + client.logger.error(err) + finally: + break + + +def non_blocking_broadcast(client, output_queue, broadcast_message_queue): + """Continuously read the output from the broadcast process and log it to the main thread.""" + try: + while not client._stop_broadcasting_event.is_set(): + while not broadcast_message_queue.empty(): + read_message_queue(client, broadcast_message_queue, target="broadcast") + + output = client._broadcast_process.stdout.readline() + if output == "" and client._broadcast_process.poll() is not None: + break + if output: + output_queue.put(output.strip()) + except Exception as e: + client.logger.error(f"Error in non_blocking_broadcast: {e}") + finally: + client._broadcast_process.stdout.close() + client._broadcast_process.wait() diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py new file mode 100644 index 000000000000..beb9bec5dbc4 --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -0,0 +1,238 @@ +"""WebSockets helpers.""" + +import logging +import re +import sys +from typing import Optional + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.provider.utils.errors import UnauthorizedError + +AUTH_TOKEN_FILTER = re.compile( + r"(auth_token=)([^&]*)", + re.IGNORECASE | re.MULTILINE, +) + +connected_clients: dict = {} + + +def clean_message(message: str) -> str: + """Clean the message.""" + return AUTH_TOKEN_FILTER.sub(r"\1********", message) + + +def get_logger(name, level=logging.INFO): + """Get a logger instance.""" + # pylint: disable=import-outside-toplevel + import logging + import uuid + + logger = logging.getLogger(f"{name}-{uuid.uuid4()}") + handler = logging.StreamHandler() + handler.setLevel(level) + formatter = logging.Formatter("%(message)s\n") + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(level) + return logger + + +async def get_status(name: str) -> dict: + """Get the status of a client.""" + if name not in connected_clients: + raise OpenBBError(f"Client {name} not connected.") + client = connected_clients[name] + provider_pid = client._psutil_process.pid if client.is_running else None + broadcast_pid = ( + client._psutil_broadcast_process.pid if client.is_broadcasting else None + ) + status = { + "name": client.name, + "auth_required": client._auth_token is not None, + "subscribed_symbols": client.symbol, + "is_running": client.is_running, + "provider_pid": provider_pid, + "is_broadcasting": client.is_broadcasting, + "broadcast_address": client.broadcast_address, + "broadcast_pid": broadcast_pid, + "results_file": client.results_file, + "table_name": client.table_name, + "save_results": client.save_results, + } + return status + + +async def check_auth(name: str, auth_token: Optional[str] = None) -> bool: + """Check the auth token.""" + if name not in connected_clients: + raise OpenBBError(f"Client {name} not connected.") + client = connected_clients[name] + if client._auth_token is None: + return True + if auth_token is None: + raise UnauthorizedError(f"Client authorization token is required for {name}.") + if auth_token != client._auth_token: + raise UnauthorizedError(f"Invalid client authorization token for {name}.") + return True + + +def handle_termination_signal(logger): + """Handle termination signals to ensure graceful shutdown.""" + # pylint: disable=import-outside-toplevel + import sys + + logger.info( + "PROVIDER INFO: Termination signal received. WebSocket connection closed." + ) + sys.exit(0) + + +def parse_kwargs(): + """Parse command line keyword arguments.""" + # pylint: disable=import-outside-toplevel + import sys + + args = sys.argv[1:].copy() + _kwargs: dict = {} + for i, arg in enumerate(args): + if "=" in arg: + key, value = arg.split("=") + _kwargs[key] = value + elif arg.startswith("--"): + key = arg[2:] + if i + 1 < len(args) and not args[i + 1].startswith("--"): + value = args[i + 1] + if isinstance(value, str) and value.lower() in ["false", "true"]: + value = value.lower() == "true" + elif isinstance(value, str) and value.lower() == "none": + value = None + _kwargs[key] = value + else: + _kwargs[key] = True + + return _kwargs + + +async def setup_database(results_path, table_name): + # pylint: disable=import-outside-toplevel + import os # noqa + import aiosqlite + + async with aiosqlite.connect(results_path) as conn: + if os.path.exists(results_path): + try: + await conn.execute("SELECT name FROM sqlite_master WHERE type='table';") + except aiosqlite.DatabaseError: + os.remove(results_path) + + async with aiosqlite.connect(results_path) as conn: + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message TEXT + ) + """ + ) + await conn.commit() + + +async def write_to_db(message, results_path, table_name, limit): + """Write the WebSocket message to the SQLite database.""" + # pylint: disable=import-outside-toplevel + import json # noqa + import aiosqlite + + conn = await aiosqlite.connect(results_path) + await conn.execute( + f"INSERT INTO {table_name} (message) VALUES (?)", # noqa + (json.dumps(message),), + ) + await conn.commit() + records = await conn.execute(f"SELECT COUNT(*) FROM {table_name}") # noqa + count = (await records.fetchone())[0] + count = await conn.execute(f"SELECT COUNT(*) FROM {table_name}") # noqa + current_count = int((await count.fetchone())[0]) + limit = 0 if limit is None else int(limit) + + if current_count > limit and limit != 0: + await conn.execute( + f""" + DELETE FROM {table_name} + WHERE id IN ( + SELECT id FROM {table_name} + ORDER BY id DESC + LIMIT -1 OFFSET ? + ) + """, # noqa: S608 + (limit,), + ) + + await conn.commit() + await conn.close() + + +class StdOutSink: + """Filter stdout for PII.""" + + def write(self, message): + """Write to stdout.""" + cleaned_message = AUTH_TOKEN_FILTER.sub(r"\1********", message) + if cleaned_message != message: + cleaned_message = f"{cleaned_message}\n" + sys.__stdout__.write(cleaned_message) + + def flush(self): + """Flush stdout.""" + sys.__stdout__.flush() + + +class AuthTokenFilter(logging.Formatter): + """Custom logging formatter to filter auth tokens.""" + + def format(self, record): + original_message = super().format(record) + cleaned_message = AUTH_TOKEN_FILTER.sub(r"\1********", original_message) + return cleaned_message + + +class MessageQueue: + def __init__(self, max_size: int = 1000, max_retries=5, backoff_factor=0.5): + """Initialize the MessageQueue.""" + # pylint: disable=import-outside-toplevel + from asyncio import Queue + + self.queue = Queue(maxsize=max_size) + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.logger = get_logger("openbb.websocket.queue") + + async def dequeue(self): + return await self.queue.get() + + async def enqueue(self, message): + """Enqueue a message.""" + # pylint: disable=import-outside-toplevel + from asyncio import sleep + from queue import Full + + retries = 0 + while retries < self.max_retries: + try: + await self.queue.put(message) + return + except Full: + retries += 1 + msg = f"Queue is full. Retrying {retries}/{self.max_retries}..." + self.logger.warning(msg) + await sleep(self.backoff_factor * retries) + self.logger.error("Failed to enqueue message after maximum retries.") + + async def process_queue(self, handler): + while True: + message = await self.queue.get() + await self._process_message(message, handler) + self.queue.task_done() + + async def _process_message(self, message, handler): + await handler(message) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/listen.py b/openbb_platform/extensions/websockets/openbb_websockets/listen.py new file mode 100644 index 000000000000..e16f96817a64 --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/listen.py @@ -0,0 +1,157 @@ +"""Convenience tool for listening to raw broadcast streams outside of the main application thread.""" + + +class Listener: + """WebSocket broadcast listener. Not intended to be initialized directly, use the 'listen' function.""" + + def __init__(self, **kwargs): + """Initialize the Listener. All keyword arguments are passed directly to websockets.connect.""" + + self.loop = None + self.websocket = None + self.current_task = None + self.kwargs = {} + if kwargs: + self.kwargs = kwargs + + async def listen(self, url, **kwargs): # noqa: PLR0915 + """Listen for WebSocket messages.""" + # pylint: disable=import-outside-toplevel + import asyncio # noqa + import json + import websockets + from openbb_core.app.model.abstract.error import OpenBBError + from openbb_core.provider.utils.errors import UnauthorizedError + from openbb_websockets.helpers import clean_message, get_logger + from websockets.exceptions import InvalidStatusCode + + kwargs = kwargs or {} + + if self.kwargs: + for k, v in self.kwargs.items(): + if k not in kwargs: + kwargs[k] = v + + self.logger = get_logger(url) + url = url.replace("http", "ws") + + if url.startswith("localhost"): + url = url.replace("localhost", "ws://localhost") + + if url[0].isdigit(): + url = f"ws://{url}" + + try: + while True: + try: + async with websockets.connect(url, **kwargs) as websocket: + self.websocket = websocket + url = clean_message(url) + self.logger.info( + f"\nListening for messages from {clean_message(url)}" + ) + for handler in self.logger.handlers: + handler.flush() + async for message in websocket: + if ( + isinstance(message, str) + and "Invalid authentication token" in message + ): + raise UnauthorizedError(message) + self.logger.info(json.loads(message)) + for handler in self.logger.handlers: + handler.flush() + except UnauthorizedError as error: + self.logger.error(error) + break + except (KeyboardInterrupt, asyncio.CancelledError): + self.logger.info("Disconnected from server.") + break + except ( + websockets.ConnectionClosedError, + asyncio.IncompleteReadError, + ): + self.logger.error( + f"The process hosting {clean_message(url)} was terminated." + ) + break + except websockets.exceptions.InvalidURI as error: + self.logger.error(f"Invalid URI -> {error}") + break + except InvalidStatusCode as error: + self.logger.error(f"Invalid status code -> {error}") + break + except OSError as error: + if "Multiple exceptions" in str(error): + err = str(error).split("Multiple exceptions:")[1].strip() + err = err.split("[")[-1].strip().replace("]", ":") + self.logger.error( + f"An error occurred while attempting to connect to: {clean_message(url)} -> {err}" + ) + else: + self.logger.error( + f"An error occurred while attempting to connect to: {clean_message(url)} -> {error}" + ) + break + except Exception as error: + self.logger.error(f"An unexpected error occurred: {error}") + raise OpenBBError(error) from error + finally: + if self.websocket: + await self.websocket.close() + + def stop(self): + if self.current_task: + self.current_task.cancel() + self.loop.run_until_complete(self.current_task) + if self.websocket: + self.loop.run_until_complete(self.websocket.close()) + if not self.loop.is_closed(): + self.loop.stop() + + async def start_listening(self, url, **kwargs): + # pylint: disable=import-outside-toplevel + import asyncio + import contextlib + + self.current_task = self.loop.create_task(self.listen(url, **kwargs)) + with contextlib.suppress(asyncio.CancelledError): + await self.current_task + + def run(self, url, **kwargs): + # pylint: disable=import-outside-toplevel + import asyncio + + try: + self.loop = asyncio.get_running_loop() + except RuntimeError: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + try: + self.loop.run_until_complete(self.start_listening(url, **kwargs)) + except KeyboardInterrupt: + self.logger.info("\nWebSocket listener terminated.") + finally: + self.stop() + + +def listen(url, **kwargs): + """Listen for WebSocket messages from a given URL. This function is blocking. + + Parameters + ---------- + url : str + The WebSocket URL to connect to. + kwargs : dict + Additional keyword arguments passed directly to websockets.connect + """ + # pylint: disable=import-outside-toplevel + from openbb_core.app.model.abstract.error import OpenBBError + + try: + listener = Listener(**kwargs) + listener.run(url, **kwargs) + except Exception as e: + raise OpenBBError(e) from e + finally: + return diff --git a/openbb_platform/extensions/websockets/openbb_websockets/models.py b/openbb_platform/extensions/websockets/openbb_websockets/models.py new file mode 100644 index 000000000000..a631ba0cf8f9 --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/models.py @@ -0,0 +1,98 @@ +"""WebSockets models.""" + +from datetime import datetime +from typing import Any, Optional + +from openbb_core.provider.abstract.data import Data +from openbb_core.provider.abstract.query_params import QueryParams +from openbb_core.provider.utils.descriptions import ( + DATA_DESCRIPTIONS, + QUERY_DESCRIPTIONS, +) +from pydantic import ConfigDict, Field, field_validator + +from openbb_websockets.client import WebSocketClient + + +class WebSocketQueryParams(QueryParams): + """Query parameters for WebSocket connection creation.""" + + symbol: str = Field( + description=QUERY_DESCRIPTIONS.get("symbol", ""), + ) + name: str = Field( + description="Name to assign the client connection.", + ) + auth_token: Optional[str] = Field( + default=None, + description="Authentication token for API access control of the client, not related to the provider credentials.", + ) + results_file: Optional[str] = Field( + default=None, + description="Absolute path to the file for continuous writing. By default, a temporary file is created.", + ) + save_results: bool = Field( + default=False, + description="Whether to save the results after the session ends.", + ) + table_name: str = Field( + default="records", + description="Name of the SQL table to write the results to.", + ) + limit: Optional[int] = Field( + default=1000, + description="Maximum number of newest records to keep in the database." + + " If None, all records are kept, which can be memory-intensive.", + ) + sleep_time: float = Field( + default=0.25, + description="Time to sleep between checking for new records in the database from the broadcast server." + + " The default is 0.25 seconds.", + ) + broadcast_host: str = Field( + default="127.0.0.1", + description="IP address to bind the broadcast server to.", + ) + broadcast_port: int = Field( + default=6666, + description="Port to bind the broadcast server to.", + ) + start_broadcast: bool = Field( + default=True, + description="Whether to start the broadcast server." + + " Set to False if system or network conditions do not allow it." + + " Can be started manually with the 'start_broadcasting' method.", + ) + + +class WebSocketData(Data): + """WebSocket data model.""" + + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + + +class WebSocketConnection(Data): + """Data model for returning WebSocketClient from the Provider Interface.""" + + __model_config__ = ConfigDict( + extra="forbid", + ) + + client: Any = Field( + description="Instance of WebSocketClient class initialized by a provider Fetcher." + + " The client is used to communicate with the provider's data stream." + + " It is not returned to the user, but is handled by the router for API access.", + exclude=True, + ) + + @field_validator("client", mode="before", check_fields=False) + def _validate_client(cls, v): + """Validate the client.""" + if not isinstance(v, WebSocketClient): + raise ValueError("Client must be an instance of WebSocketClient.") + return v diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py new file mode 100644 index 000000000000..18bbbb85bc15 --- /dev/null +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -0,0 +1,428 @@ +"""Websockets Router.""" + +# pylint: disable=unused-argument + +import asyncio +import sys +from typing import Any, Optional + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.app.model.command_context import CommandContext +from openbb_core.app.model.example import APIEx +from openbb_core.app.model.obbject import OBBject +from openbb_core.app.provider_interface import ( + ExtraParams, + ProviderChoices, + StandardParams, +) +from openbb_core.app.query import Query +from openbb_core.app.router import Router +from openbb_core.provider.utils.errors import EmptyDataError + +from openbb_websockets.helpers import ( + StdOutSink, + check_auth, + connected_clients, + get_status, +) + +router = Router("", description="WebSockets Router") +sys.stdout = StdOutSink() + + +@router.command( + model="WebSocketConnection", +) +async def create_connection( + cc: CommandContext, + provider_choices: ProviderChoices, + standard_params: StandardParams, + extra_params: ExtraParams, +) -> OBBject: + """Create a new provider websocket connection.""" + name = extra_params.name + if name in connected_clients: + broadcast_address = connected_clients[name].broadcast_address + is_running = connected_clients[name].is_running + if broadcast_address or is_running: + raise OpenBBError( + f"Client {name} already connected! Broadcasting to: {broadcast_address}" + ) + raise OpenBBError(f"Client {name} already connected but not running.") + del name + + obbject = await OBBject.from_query(Query(**locals())) + client = obbject.results.client + + await asyncio.sleep(1) + + if not client.is_running: + client._atexit() + raise OpenBBError("Client failed to connect.") + + if hasattr(extra_params, "start_broadcast") and extra_params.start_broadcast: + client.start_broadcasting() + + client_name = client.name + connected_clients[client_name] = client + results = await get_status(client_name) + + obbject.results = results + + return obbject + + +@router.command( + methods=["GET"], +) +async def get_results(name: str, auth_token: Optional[str] = None) -> OBBject: + """Get all recorded results from a client connection. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + list[Data] + The recorded results from the client. + """ + + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + if not client.results: + raise EmptyDataError(f"No results recorded for client {name}.") + try: + return OBBject(results=client.transformed_results) + except NotImplementedError: + return OBBject(results=client.results) + + +@router.command( + methods=["GET"], +) +async def clear_results(name: str, auth_token: Optional[str] = None) -> OBBject[str]: + """Clear all stored results from a client connection. Does not stop the client or broadcast. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The number of results cleared from the client. + """ + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + n_before = len(client.results) + del client.results + return OBBject(results=f"{n_before} results cleared from {name}.") + + +@router.command( + methods=["GET"], +) +async def subscribe( + name: str, symbol: str, auth_token: Optional[str] = None +) -> OBBject[str]: + """Subscribe to a new symbol. + + Parameters + ---------- + name : str + The name of the client. + symbol : str + The symbol to subscribe to. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The message that the client subscribed to the symbol. + """ + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + symbols = client.symbol.split(",") + if symbols and symbol in symbols: + raise OpenBBError(f"Client {name} already subscribed to {symbol}.") + client.subscribe(symbol) + # await asyncio.sleep(2) + if client.is_running: + return OBBject(results=f"Added {symbol} to client {name} connection.") + client.logger.error( + f"Client {name} failed to subscribe to {symbol} and is not running." + ) + + +@router.command( + methods=["GET"], +) +async def unsubscribe( + name: str, symbol: str, auth_token: Optional[str] = None +) -> OBBject[str]: + """Unsubscribe to a symbol. + + Parameters + ---------- + name : str + The name of the client. + symbol : str + The symbol to unsubscribe from. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The message that the client unsubscribed from the symbol. + """ + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + symbols = client.symbol.split(",") + if symbol not in symbols: + raise OpenBBError(f"Client {name} not subscribed to {symbol}.") + client.unsubscribe(symbol) + # await asyncio.sleep(2) + if client.is_running: + return OBBject(results=f"Client {name} unsubscribed to {symbol}.") + client.logger.error( + f"Client {name} failed to unsubscribe to {symbol} and is not running." + ) + + +@router.command( + methods=["GET"], +) +async def get_client_status(name: str = "all") -> OBBject[list[dict]]: + """Get the status of a client, or all client connections. + + Parameters + ---------- + name : str + The name of the client. Default is "all". + + Returns + ------- + list[dict] + The status of the client(s). + """ + if not connected_clients: + raise OpenBBError("No active connections.") + if name == "all": + connections = [ + await get_status(client.name) for client in connected_clients.values() + ] + else: + connections = [await get_status(name)] + return OBBject(results=connections) + + +@router.command( + methods=["GET"], + include_in_schema=False, +) +async def get_client(name: str, auth_token: Optional[str] = None) -> OBBject: + """Get an open client connection object. This endpoint is only available from the Python interface. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + WebSocketClient + The provider client connection object. + """ + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + return OBBject(results=client) + + +@router.command( + methods=["GET"], +) +async def stop_connection(name: str, auth_token: Optional[str] = None) -> OBBject[str]: + """Stop a the connection to the provider's websocket. Does not stop the broadcast server. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The message that the provider connection was stopped. + """ + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + client.disconnect() + return OBBject( + results=f"Client {name} connection to the provider's websocket was stopped." + ) + + +@router.command( + methods=["GET"], +) +async def restart_connection( + name: str, auth_token: Optional[str] = None +) -> OBBject[str]: + """Restart a websocket connection. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The message that the client connection was restarted. + """ + if name not in connected_clients: + raise OpenBBError(f"No active client named, {name}. Use create_connection.") + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + client.connect() + return OBBject(results=f"Client {name} connection was restarted.") + + +@router.command( + methods=["GET"], +) +async def stop_broadcasting( + name: str, auth_token: Optional[str] = None +) -> OBBject[str]: + """Stop the broadcast server. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The message that the client stopped broadcasting to the address. + """ + if name not in connected_clients: + raise OpenBBError(f"Client {name} not connected.") + + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + + client = connected_clients[name] + + if not client.is_broadcasting: + raise OpenBBError(f"Client {name} not broadcasting.") + + old_address = client.broadcast_address + client.stop_broadcasting() + + if not client.is_running: + client._atexit() + del connected_clients[name] + return OBBject( + results=f"Client {name} stopped broadcasting and was not running, client removed." + ) + + return OBBject(results=f"Client {name} stopped broadcasting to: {old_address}") + + +@router.command( + methods=["GET"], +) +async def start_broadcasting( + name: str, + auth_token: Optional[str] = None, + host: str = "127.0.0.1", + port: int = 6666, + uvicorn_kwargs: Optional[dict[str, Any]] = None, +) -> OBBject[str]: + """Start broadcasting from a websocket. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + host : str + The host address to broadcast to. Default is 127.0.0.1" + port : int + The port to broadcast to. Default is 6666. + uvicorn_kwargs : Optional[dict[str, Any]] + Additional keyword arguments for passing directly to the uvicorn server. + + Returns + ------- + str + The message that the client started broadcasting. + """ + if name not in connected_clients: + raise OpenBBError(f"Client {name} not connected.") + if not await check_auth(name, auth_token): + raise OpenBBError("Error finding client.") + client = connected_clients[name] + kwargs = uvicorn_kwargs if uvicorn_kwargs else {} + client.start_broadcasting(host=host, port=port, **kwargs) + + await asyncio.sleep(2) + if not client.is_broadcasting: + raise OpenBBError(f"Client {name} failed to broadcast.") + return OBBject( + results=f"Client {name} started broadcasting to {client.broadcast_address}." + ) + + +@router.command( + methods=["GET"], +) +async def kill(name: str, auth_token: Optional[str] = None) -> OBBject[str]: + """Kills a client. + + Parameters + ---------- + name : str + The name of the client. + auth_token : Optional[str] + The client's authorization token. + + Returns + ------- + str + The message that the client was killed. + """ + if not connected_clients: + raise OpenBBError("No connections to kill.") + elif name and name not in connected_clients: + raise OpenBBError(f"Client {name} not connected.") + client = connected_clients[name] + client._atexit() + del connected_clients[name] + return OBBject(results=f"Clients {name} killed.") diff --git a/openbb_platform/extensions/websockets/poetry.lock b/openbb_platform/extensions/websockets/poetry.lock new file mode 100644 index 000000000000..88faa6559e70 --- /dev/null +++ b/openbb_platform/extensions/websockets/poetry.lock @@ -0,0 +1,1618 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.3" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572"}, + {file = "aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586"}, +] + +[[package]] +name = "aiohttp" +version = "3.10.10" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.10.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be7443669ae9c016b71f402e43208e13ddf00912f47f623ee5994e12fc7d4b3f"}, + {file = "aiohttp-3.10.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b06b7843929e41a94ea09eb1ce3927865387e3e23ebe108e0d0d09b08d25be9"}, + {file = "aiohttp-3.10.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:333cf6cf8e65f6a1e06e9eb3e643a0c515bb850d470902274239fea02033e9a8"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:274cfa632350225ce3fdeb318c23b4a10ec25c0e2c880eff951a3842cf358ac1"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9e5e4a85bdb56d224f412d9c98ae4cbd032cc4f3161818f692cd81766eee65a"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b606353da03edcc71130b52388d25f9a30a126e04caef1fd637e31683033abd"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab5a5a0c7a7991d90446a198689c0535be89bbd6b410a1f9a66688f0880ec026"}, + {file = "aiohttp-3.10.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:578a4b875af3e0daaf1ac6fa983d93e0bbfec3ead753b6d6f33d467100cdc67b"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8105fd8a890df77b76dd3054cddf01a879fc13e8af576805d667e0fa0224c35d"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3bcd391d083f636c06a68715e69467963d1f9600f85ef556ea82e9ef25f043f7"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fbc6264158392bad9df19537e872d476f7c57adf718944cc1e4495cbabf38e2a"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e48d5021a84d341bcaf95c8460b152cfbad770d28e5fe14a768988c461b821bc"}, + {file = "aiohttp-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2609e9ab08474702cc67b7702dbb8a80e392c54613ebe80db7e8dbdb79837c68"}, + {file = "aiohttp-3.10.10-cp310-cp310-win32.whl", hash = "sha256:84afcdea18eda514c25bc68b9af2a2b1adea7c08899175a51fe7c4fb6d551257"}, + {file = "aiohttp-3.10.10-cp310-cp310-win_amd64.whl", hash = "sha256:9c72109213eb9d3874f7ac8c0c5fa90e072d678e117d9061c06e30c85b4cf0e6"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb"}, + {file = "aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7"}, + {file = "aiohttp-3.10.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1"}, + {file = "aiohttp-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a"}, + {file = "aiohttp-3.10.10-cp311-cp311-win32.whl", hash = "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94"}, + {file = "aiohttp-3.10.10-cp311-cp311-win_amd64.whl", hash = "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28"}, + {file = "aiohttp-3.10.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16"}, + {file = "aiohttp-3.10.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a"}, + {file = "aiohttp-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205"}, + {file = "aiohttp-3.10.10-cp312-cp312-win32.whl", hash = "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628"}, + {file = "aiohttp-3.10.10-cp312-cp312-win_amd64.whl", hash = "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d"}, + {file = "aiohttp-3.10.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e"}, + {file = "aiohttp-3.10.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67"}, + {file = "aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b"}, + {file = "aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8"}, + {file = "aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1b66ccafef7336a1e1f0e389901f60c1d920102315a56df85e49552308fc0486"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acd48d5b80ee80f9432a165c0ac8cbf9253eaddb6113269a5e18699b33958dbb"}, + {file = "aiohttp-3.10.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3455522392fb15ff549d92fbf4b73b559d5e43dc522588f7eb3e54c3f38beee7"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45c3b868724137f713a38376fef8120c166d1eadd50da1855c112fe97954aed8"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da1dee8948d2137bb51fbb8a53cce6b1bcc86003c6b42565f008438b806cccd8"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5ce2ce7c997e1971b7184ee37deb6ea9922ef5163c6ee5aa3c274b05f9e12fa"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28529e08fde6f12eba8677f5a8608500ed33c086f974de68cc65ab218713a59d"}, + {file = "aiohttp-3.10.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7db54c7914cc99d901d93a34704833568d86c20925b2762f9fa779f9cd2e70f"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03a42ac7895406220124c88911ebee31ba8b2d24c98507f4a8bf826b2937c7f2"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7e338c0523d024fad378b376a79faff37fafb3c001872a618cde1d322400a572"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:038f514fe39e235e9fef6717fbf944057bfa24f9b3db9ee551a7ecf584b5b480"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:64f6c17757251e2b8d885d728b6433d9d970573586a78b78ba8929b0f41d045a"}, + {file = "aiohttp-3.10.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:93429602396f3383a797a2a70e5f1de5df8e35535d7806c9f91df06f297e109b"}, + {file = "aiohttp-3.10.10-cp38-cp38-win32.whl", hash = "sha256:c823bc3971c44ab93e611ab1a46b1eafeae474c0c844aff4b7474287b75fe49c"}, + {file = "aiohttp-3.10.10-cp38-cp38-win_amd64.whl", hash = "sha256:54ca74df1be3c7ca1cf7f4c971c79c2daf48d9aa65dea1a662ae18926f5bc8ce"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:01948b1d570f83ee7bbf5a60ea2375a89dfb09fd419170e7f5af029510033d24"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9fc1500fd2a952c5c8e3b29aaf7e3cc6e27e9cfc0a8819b3bce48cc1b849e4cc"}, + {file = "aiohttp-3.10.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f614ab0c76397661b90b6851a030004dac502e48260ea10f2441abd2207fbcc7"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00819de9e45d42584bed046314c40ea7e9aea95411b38971082cad449392b08c"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05646ebe6b94cc93407b3bf34b9eb26c20722384d068eb7339de802154d61bc5"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:998f3bd3cfc95e9424a6acd7840cbdd39e45bc09ef87533c006f94ac47296090"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9010c31cd6fa59438da4e58a7f19e4753f7f264300cd152e7f90d4602449762"}, + {file = "aiohttp-3.10.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ea7ffc6d6d6f8a11e6f40091a1040995cdff02cfc9ba4c2f30a516cb2633554"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ef9c33cc5cbca35808f6c74be11eb7f5f6b14d2311be84a15b594bd3e58b5527"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce0cdc074d540265bfeb31336e678b4e37316849d13b308607efa527e981f5c2"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:597a079284b7ee65ee102bc3a6ea226a37d2b96d0418cc9047490f231dc09fe8"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7789050d9e5d0c309c706953e5e8876e38662d57d45f936902e176d19f1c58ab"}, + {file = "aiohttp-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e7f8b04d83483577fd9200461b057c9f14ced334dcb053090cea1da9c8321a91"}, + {file = "aiohttp-3.10.10-cp39-cp39-win32.whl", hash = "sha256:c02a30b904282777d872266b87b20ed8cc0d1501855e27f831320f471d54d983"}, + {file = "aiohttp-3.10.10-cp39-cp39-win_amd64.whl", hash = "sha256:edfe3341033a6b53a5c522c802deb2079eee5cbfbb0af032a55064bd65c73a23"}, + {file = "aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.12.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.6.2.post1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +files = [ + {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, + {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.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.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.115.4" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"}, + {file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.42.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "frozenlist" +version = "1.5.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"}, + {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"}, + {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"}, + {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"}, + {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"}, + {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"}, + {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"}, + {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"}, + {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"}, + {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"}, + {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"}, + {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"}, + {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"}, + {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"}, + {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, +] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "html5lib" +version = "1.1" +description = "HTML parser based on the WHATWG HTML specification" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"}, + {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, +] + +[package.dependencies] +six = ">=1.9" +webencodings = "*" + +[package.extras] +all = ["chardet (>=2.2)", "genshi", "lxml"] +chardet = ["chardet (>=2.2)"] +genshi = ["genshi"] +lxml = ["lxml"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "monotonic" +version = "1.6" +description = "An implementation of time.monotonic() for Python 2 & < 3.3" +optional = false +python-versions = "*" +files = [ + {file = "monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c"}, + {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"}, +] + +[[package]] +name = "multidict" +version = "6.1.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.8" +files = [ + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, + {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, + {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, + {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, + {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, + {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, + {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, + {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, + {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, + {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, + {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, + {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, + {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, + {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, + {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "numpy" +version = "2.0.2" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, + {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, + {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, + {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, + {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, + {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, + {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, + {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, + {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, + {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, +] + +[[package]] +name = "numpy" +version = "2.1.2" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "numpy-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30d53720b726ec36a7f88dc873f0eec8447fbc93d93a8f079dfac2629598d6ee"}, + {file = "numpy-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d3ca0a72dd8846eb6f7dfe8f19088060fcb76931ed592d29128e0219652884"}, + {file = "numpy-2.1.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:fc44e3c68ff00fd991b59092a54350e6e4911152682b4782f68070985aa9e648"}, + {file = "numpy-2.1.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:7c1c60328bd964b53f8b835df69ae8198659e2b9302ff9ebb7de4e5a5994db3d"}, + {file = "numpy-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdb606a7478f9ad91c6283e238544451e3a95f30fb5467fbf715964341a8a86"}, + {file = "numpy-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d666cb72687559689e9906197e3bec7b736764df6a2e58ee265e360663e9baf7"}, + {file = "numpy-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c6eef7a2dbd0abfb0d9eaf78b73017dbfd0b54051102ff4e6a7b2980d5ac1a03"}, + {file = "numpy-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:12edb90831ff481f7ef5f6bc6431a9d74dc0e5ff401559a71e5e4611d4f2d466"}, + {file = "numpy-2.1.2-cp310-cp310-win32.whl", hash = "sha256:a65acfdb9c6ebb8368490dbafe83c03c7e277b37e6857f0caeadbbc56e12f4fb"}, + {file = "numpy-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:860ec6e63e2c5c2ee5e9121808145c7bf86c96cca9ad396c0bd3e0f2798ccbe2"}, + {file = "numpy-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b42a1a511c81cc78cbc4539675713bbcf9d9c3913386243ceff0e9429ca892fe"}, + {file = "numpy-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:faa88bc527d0f097abdc2c663cddf37c05a1c2f113716601555249805cf573f1"}, + {file = "numpy-2.1.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:c82af4b2ddd2ee72d1fc0c6695048d457e00b3582ccde72d8a1c991b808bb20f"}, + {file = "numpy-2.1.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:13602b3174432a35b16c4cfb5de9a12d229727c3dd47a6ce35111f2ebdf66ff4"}, + {file = "numpy-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ebec5fd716c5a5b3d8dfcc439be82a8407b7b24b230d0ad28a81b61c2f4659a"}, + {file = "numpy-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2b49c3c0804e8ecb05d59af8386ec2f74877f7ca8fd9c1e00be2672e4d399b1"}, + {file = "numpy-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2cbba4b30bf31ddbe97f1c7205ef976909a93a66bb1583e983adbd155ba72ac2"}, + {file = "numpy-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8e00ea6fc82e8a804433d3e9cedaa1051a1422cb6e443011590c14d2dea59146"}, + {file = "numpy-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5006b13a06e0b38d561fab5ccc37581f23c9511879be7693bd33c7cd15ca227c"}, + {file = "numpy-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:f1eb068ead09f4994dec71c24b2844f1e4e4e013b9629f812f292f04bd1510d9"}, + {file = "numpy-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7bf0a4f9f15b32b5ba53147369e94296f5fffb783db5aacc1be15b4bf72f43b"}, + {file = "numpy-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b1d0fcae4f0949f215d4632be684a539859b295e2d0cb14f78ec231915d644db"}, + {file = "numpy-2.1.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f751ed0a2f250541e19dfca9f1eafa31a392c71c832b6bb9e113b10d050cb0f1"}, + {file = "numpy-2.1.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bd33f82e95ba7ad632bc57837ee99dba3d7e006536200c4e9124089e1bf42426"}, + {file = "numpy-2.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8cde4f11f0a975d1fd59373b32e2f5a562ade7cde4f85b7137f3de8fbb29a0"}, + {file = "numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d95f286b8244b3649b477ac066c6906fbb2905f8ac19b170e2175d3d799f4df"}, + {file = "numpy-2.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ab4754d432e3ac42d33a269c8567413bdb541689b02d93788af4131018cbf366"}, + {file = "numpy-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e585c8ae871fd38ac50598f4763d73ec5497b0de9a0ab4ef5b69f01c6a046142"}, + {file = "numpy-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9c6c754df29ce6a89ed23afb25550d1c2d5fdb9901d9c67a16e0b16eaf7e2550"}, + {file = "numpy-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:456e3b11cb79ac9946c822a56346ec80275eaf2950314b249b512896c0d2505e"}, + {file = "numpy-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a84498e0d0a1174f2b3ed769b67b656aa5460c92c9554039e11f20a05650f00d"}, + {file = "numpy-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4d6ec0d4222e8ffdab1744da2560f07856421b367928026fb540e1945f2eeeaf"}, + {file = "numpy-2.1.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:259ec80d54999cc34cd1eb8ded513cb053c3bf4829152a2e00de2371bd406f5e"}, + {file = "numpy-2.1.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:675c741d4739af2dc20cd6c6a5c4b7355c728167845e3c6b0e824e4e5d36a6c3"}, + {file = "numpy-2.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b2d4e667895cc55e3ff2b56077e4c8a5604361fc21a042845ea3ad67465aa8"}, + {file = "numpy-2.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43cca367bf94a14aca50b89e9bc2061683116cfe864e56740e083392f533ce7a"}, + {file = "numpy-2.1.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:76322dcdb16fccf2ac56f99048af32259dcc488d9b7e25b51e5eca5147a3fb98"}, + {file = "numpy-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32e16a03138cabe0cb28e1007ee82264296ac0983714094380b408097a418cfe"}, + {file = "numpy-2.1.2-cp313-cp313-win32.whl", hash = "sha256:242b39d00e4944431a3cd2db2f5377e15b5785920421993770cddb89992c3f3a"}, + {file = "numpy-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f2ded8d9b6f68cc26f8425eda5d3877b47343e68ca23d0d0846f4d312ecaa445"}, + {file = "numpy-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ffef621c14ebb0188a8633348504a35c13680d6da93ab5cb86f4e54b7e922b5"}, + {file = "numpy-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad369ed238b1959dfbade9018a740fb9392c5ac4f9b5173f420bd4f37ba1f7a0"}, + {file = "numpy-2.1.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d82075752f40c0ddf57e6e02673a17f6cb0f8eb3f587f63ca1eaab5594da5b17"}, + {file = "numpy-2.1.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:1600068c262af1ca9580a527d43dc9d959b0b1d8e56f8a05d830eea39b7c8af6"}, + {file = "numpy-2.1.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a26ae94658d3ba3781d5e103ac07a876b3e9b29db53f68ed7df432fd033358a8"}, + {file = "numpy-2.1.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13311c2db4c5f7609b462bc0f43d3c465424d25c626d95040f073e30f7570e35"}, + {file = "numpy-2.1.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:2abbf905a0b568706391ec6fa15161fad0fb5d8b68d73c461b3c1bab6064dd62"}, + {file = "numpy-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ef444c57d664d35cac4e18c298c47d7b504c66b17c2ea91312e979fcfbdfb08a"}, + {file = "numpy-2.1.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bdd407c40483463898b84490770199d5714dcc9dd9b792f6c6caccc523c00952"}, + {file = "numpy-2.1.2-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:da65fb46d4cbb75cb417cddf6ba5e7582eb7bb0b47db4b99c9fe5787ce5d91f5"}, + {file = "numpy-2.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c193d0b0238638e6fc5f10f1b074a6993cb13b0b431f64079a509d63d3aa8b7"}, + {file = "numpy-2.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a7d80b2e904faa63068ead63107189164ca443b42dd1930299e0d1cb041cec2e"}, + {file = "numpy-2.1.2.tar.gz", hash = "sha256:13532a088217fa624c99b843eeb54640de23b3414b14aa66d023805eb731066c"}, +] + +[[package]] +name = "openbb-core" +version = "1.3.5" +description = "OpenBB package with core functionality." +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "openbb_core-1.3.5-py3-none-any.whl", hash = "sha256:bb93b2343eea06faff2644e03fd6ee7a09c37392c5486d96021aa9ae137e1a90"}, + {file = "openbb_core-1.3.5.tar.gz", hash = "sha256:d8abed2a351a0bca1b02fdaba439574b452a7a812d72d5e059f2dbedab55bd19"}, +] + +[package.dependencies] +aiohttp = ">=3.10.4,<4.0.0" +fastapi = ">=0.115,<0.116" +html5lib = ">=1.1,<2.0" +importlib-metadata = ">=6.8.0" +pandas = ">=1.5.3" +posthog = ">=3.3.1,<4.0.0" +pydantic = ">=2.5.1,<3.0.0" +pyjwt = ">=2.8.0,<3.0.0" +python-dotenv = ">=1.0.0,<2.0.0" +python-multipart = ">=0.0.7,<0.0.8" +requests = ">=2.32.1,<3.0.0" +ruff = ">=0.7,<0.8" +uuid7 = ">=0.1.0,<0.2.0" +uvicorn = ">=0.32.0,<0.33.0" +websockets = ">=13.0,<14.0" + +[[package]] +name = "pandas" +version = "2.2.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, + {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, + {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"}, + {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, + {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"}, + {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, + {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, + {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, + {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, + {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"}, + {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, + {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"}, + {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, + {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, + {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, + {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, + {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"}, + {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, + {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"}, + {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, + {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, + {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, + {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, + {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"}, + {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, + {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"}, + {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, + {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, + {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, + {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, + {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"}, + {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, + {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"}, + {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, + {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, + {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, + {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"}, + {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, + {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"}, + {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, + {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, + {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "posthog" +version = "3.7.0" +description = "Integrate PostHog into any python application." +optional = false +python-versions = "*" +files = [ + {file = "posthog-3.7.0-py2.py3-none-any.whl", hash = "sha256:3555161c3a9557b5666f96d8e1f17f410ea0f07db56e399e336a1656d4e5c722"}, + {file = "posthog-3.7.0.tar.gz", hash = "sha256:b095d4354ba23f8b346ab5daed8ecfc5108772f922006982dfe8b2d29ebc6e0e"}, +] + +[package.dependencies] +backoff = ">=1.10.0" +monotonic = ">=1.5" +python-dateutil = ">2.1" +requests = ">=2.7,<3.0" +six = ">=1.5" + +[package.extras] +dev = ["black", "flake8", "flake8-print", "isort", "pre-commit"] +sentry = ["django", "sentry-sdk"] +test = ["coverage", "django", "flake8", "freezegun (==0.3.15)", "mock (>=2.0.0)", "pylint", "pytest", "pytest-timeout"] + +[[package]] +name = "propcache" +version = "0.2.0" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.8" +files = [ + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336"}, + {file = "propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad"}, + {file = "propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b"}, + {file = "propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1"}, + {file = "propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348"}, + {file = "propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5"}, + {file = "propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544"}, + {file = "propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032"}, + {file = "propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed"}, + {file = "propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d"}, + {file = "propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798"}, + {file = "propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9"}, + {file = "propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df"}, + {file = "propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036"}, + {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"}, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +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.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.7" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python_multipart-0.0.7-py3-none-any.whl", hash = "sha256:b1fef9a53b74c795e2347daac8c54b252d9e0df9c619712691c1cc8021bd3c49"}, + {file = "python_multipart-0.0.7.tar.gz", hash = "sha256:288a6c39b06596c1b988bb6794c6fbc80e6c369e35e5062637df256bee0c9af9"}, +] + +[package.extras] +dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==2.2.0)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] + +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[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 = "ruff" +version = "0.7.1" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"}, + {file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"}, + {file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"}, + {file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"}, + {file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"}, + {file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"}, + {file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"}, +] + +[[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.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.41.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d"}, + {file = "starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uuid7" +version = "0.1.0" +description = "UUID version 7, generating time-sorted UUIDs with 200ns time resolution and 48 bits of randomness" +optional = false +python-versions = ">=3.7" +files = [ + {file = "uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61"}, + {file = "uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c"}, +] + +[[package]] +name = "uvicorn" +version = "0.32.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.32.0-py3-none-any.whl", hash = "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82"}, + {file = "uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[[package]] +name = "websockets" +version = "13.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, + {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, + {file = "websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6"}, + {file = "websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b"}, + {file = "websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa"}, + {file = "websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700"}, + {file = "websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c"}, + {file = "websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0"}, + {file = "websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f"}, + {file = "websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe"}, + {file = "websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a"}, + {file = "websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19"}, + {file = "websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5"}, + {file = "websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd"}, + {file = "websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02"}, + {file = "websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7"}, + {file = "websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096"}, + {file = "websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084"}, + {file = "websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3"}, + {file = "websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9"}, + {file = "websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f"}, + {file = "websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557"}, + {file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"}, + {file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"}, + {file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"}, + {file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"}, + {file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"}, + {file = "websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9"}, + {file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"}, + {file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"}, + {file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"}, + {file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"}, + {file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"}, + {file = "websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6"}, + {file = "websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708"}, + {file = "websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418"}, + {file = "websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a"}, + {file = "websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f"}, + {file = "websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5"}, + {file = "websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135"}, + {file = "websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2"}, + {file = "websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6"}, + {file = "websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d"}, + {file = "websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2"}, + {file = "websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d"}, + {file = "websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23"}, + {file = "websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c"}, + {file = "websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea"}, + {file = "websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7"}, + {file = "websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54"}, + {file = "websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db"}, + {file = "websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295"}, + {file = "websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96"}, + {file = "websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf"}, + {file = "websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6"}, + {file = "websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d"}, + {file = "websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7"}, + {file = "websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a"}, + {file = "websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa"}, + {file = "websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa"}, + {file = "websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79"}, + {file = "websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17"}, + {file = "websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6"}, + {file = "websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5"}, + {file = "websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c"}, + {file = "websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d"}, + {file = "websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238"}, + {file = "websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5"}, + {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9"}, + {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6"}, + {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a"}, + {file = "websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23"}, + {file = "websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b"}, + {file = "websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51"}, + {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7"}, + {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d"}, + {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027"}, + {file = "websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978"}, + {file = "websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e"}, + {file = "websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09"}, + {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842"}, + {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb"}, + {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20"}, + {file = "websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678"}, + {file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"}, + {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, +] + +[[package]] +name = "yarl" +version = "1.17.1" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +files = [ + {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da"}, + {file = "yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3"}, + {file = "yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f"}, + {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931"}, + {file = "yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b"}, + {file = "yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988"}, + {file = "yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d"}, + {file = "yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4"}, + {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4"}, + {file = "yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7"}, + {file = "yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d"}, + {file = "yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147"}, + {file = "yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c"}, + {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199"}, + {file = "yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96"}, + {file = "yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374"}, + {file = "yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e"}, + {file = "yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299"}, + {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258"}, + {file = "yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2"}, + {file = "yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159"}, + {file = "yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934"}, + {file = "yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5"}, + {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f"}, + {file = "yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473"}, + {file = "yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138"}, + {file = "yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06"}, + {file = "yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.0" + +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "891d55f4c0fd00c315fb2a7f25dfcbfbe0991a9861b1aa66f5378eb82f614063" diff --git a/openbb_platform/extensions/websockets/pyproject.toml b/openbb_platform/extensions/websockets/pyproject.toml new file mode 100644 index 000000000000..62f5ca59fe06 --- /dev/null +++ b/openbb_platform/extensions/websockets/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "openbb-websockets" +version = "1.0.0b" +description = "Websockets extension for OpenBB" +authors = ["OpenBB Team "] +license = "AGPL-3.0-only" +readme = "README.md" +packages = [{ include = "openbb_websockets" }] + +[tool.poetry.dependencies] +python = "^3.9" +openbb-core = "^1.3.5" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.plugins."openbb_core_extension"] +websockets = "openbb_websockets.websockets_router:router" diff --git a/openbb_platform/providers/fmp/openbb_fmp/__init__.py b/openbb_platform/providers/fmp/openbb_fmp/__init__.py index 3d85d3ed4509..c2531817f93d 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/__init__.py +++ b/openbb_platform/providers/fmp/openbb_fmp/__init__.py @@ -63,6 +63,7 @@ from openbb_fmp.models.risk_premium import FMPRiskPremiumFetcher from openbb_fmp.models.share_statistics import FMPShareStatisticsFetcher from openbb_fmp.models.treasury_rates import FMPTreasuryRatesFetcher +from openbb_fmp.models.websocket_connection import FMPWebSocketFetcher from openbb_fmp.models.world_news import FMPWorldNewsFetcher from openbb_fmp.models.yield_curve import FMPYieldCurveFetcher @@ -137,6 +138,7 @@ "TreasuryRates": FMPTreasuryRatesFetcher, "WorldNews": FMPWorldNewsFetcher, "EtfHistorical": FMPEquityHistoricalFetcher, + "WebSocketConnection": FMPWebSocketFetcher, "YieldCurve": FMPYieldCurveFetcher, }, repr_name="Financial Modeling Prep (FMP)", diff --git a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py new file mode 100644 index 000000000000..193990d280c8 --- /dev/null +++ b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py @@ -0,0 +1,198 @@ +"""FMP WebSocket model.""" + +from datetime import datetime +from typing import Any, Literal, Optional + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_websockets.client import WebSocketClient +from openbb_websockets.models import ( + WebSocketConnection, + WebSocketData, + WebSocketQueryParams, +) +from pydantic import Field, field_validator + +URL_MAP = { + "stock": "wss://websockets.financialmodelingprep.com", + "fx": "wss://forex.financialmodelingprep.com", + "crypto": "wss://crypto.financialmodelingprep.com", +} + + +class FmpWebSocketQueryParams(WebSocketQueryParams): + """FMP WebSocket query parameters.""" + + __json_schema_extra__ = { + "symbol": {"multiple_items_allowed": True}, + "asset_type": { + "multiple_items_allowed": False, + "choices": ["stock", "fx", "crypto"], + }, + } + + symbol: str = Field( + description="The symbol(s) of the asset to fetch data for.", + ) + asset_type: Literal["stock", "fx", "crypto"] = Field( + default="crypto", + description="The asset type, required for the provider URI.", + ) + + +class FmpWebSocketData(WebSocketData): + """FMP WebSocket data model.""" + + __alias_dict__ = { + "symbol": "s", + "date": "t", + "exchange": "e", + "type": "type", + "bid_size": "bs", + "bid_price": "bp", + "ask_size": "as", + "ask_price": "ap", + "last_price": "lp", + "last_size": "ls", + } + + symbol: str = Field( + description="The symbol of the asset.", + ) + date: datetime = Field( + description="The datetime of the data.", + ) + exchange: Optional[str] = Field( + default=None, + description="The exchange of the data.", + ) + type: Literal["quote", "trade", "break"] = Field( + description="The type of data.", + ) + bid_price: Optional[float] = Field( + default=None, + description="The price of the bid.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + bid_size: Optional[float] = Field( + default=None, + description="The size of the bid.", + ) + ask_size: Optional[float] = Field( + default=None, + description="The size of the ask.", + ) + ask_price: Optional[float] = Field( + default=None, + description="The price of the ask.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + last_price: Optional[float] = Field( + default=None, + description="The last trade price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + last_size: Optional[float] = Field( + default=None, + description="The size of the trade.", + ) + + @field_validator("type", mode="before", check_fields=False) + def _valiidate_data_type(cls, v): + """Validate the data type.""" + return ( + "quote" if v == "Q" else "trade" if v == "T" else "break" if v == "B" else v + ) + + @field_validator("date", mode="before", check_fields=False) + def _validate_date(cls, v): + """Validate the date.""" + # pylint: disable=import-outside-toplevel + from pytz import timezone + + if isinstance(v, str): + return datetime.fromisoformat(v) + try: + return datetime.fromtimestamp(v / 1000) + except Exception: + if isinstance(v, (int, float)): + # Check if the timestamp is in nanoseconds and convert to seconds + if v > 1e12: + v = v / 1e9 # Convert nanoseconds to seconds + dt = datetime.fromtimestamp(v) + dt = timezone("America/New_York").localize(dt) + return dt + return v + + +class FmpWebSocketConnection(WebSocketConnection): + """FMP WebSocket connection model.""" + + +class FMPWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnection]): + """FMP WebSocket model.""" + + @staticmethod + def transform_query(params: dict[str, Any]) -> FmpWebSocketQueryParams: + """Transform the query parameters.""" + return FmpWebSocketQueryParams(**params) + + @staticmethod + def extract_data( + query: FmpWebSocketQueryParams, + credentials: Optional[dict[str, str]], + **kwargs: Any, + ) -> WebSocketClient: + """Extract data from the WebSocket.""" + # pylint: disable=import-outside-toplevel + import time + + api_key = credentials.get("fmp_api_key") if credentials else "" + url = URL_MAP[query.asset_type] + + symbol = query.symbol.lower() + + kwargs = { + "url": url, + "api_key": api_key, + } + + client = WebSocketClient( + name=query.name, + module="openbb_fmp.utils.websocket_client", + symbol=symbol, + limit=query.limit, + results_file=query.results_file, + table_name=query.table_name, + save_results=query.save_results, + data_model=FmpWebSocketData, + sleep_time=query.sleep_time, + broadcast_host=query.broadcast_host, + broadcast_port=query.broadcast_port, + auth_token=query.auth_token, + **kwargs, + ) + + try: + client.connect() + + except Exception as e: # pylint: disable=broad-except + client.disconnect() + raise OpenBBError(e) from e + + time.sleep(1) + + if client.is_running: + return client + + client.disconnect() + raise OpenBBError("Failed to connect to the WebSocket.") + + @staticmethod + def transform_data( + data: WebSocketClient, + query: FmpWebSocketQueryParams, + **kwargs: Any, + ) -> FmpWebSocketConnection: + """Return the client as an instance of Data.""" + return FmpWebSocketConnection(client=data) diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py new file mode 100644 index 000000000000..125dc076a365 --- /dev/null +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -0,0 +1,190 @@ +"""FMP WebSocket server.""" + +import asyncio +import json +import os +import signal +import sys + +import websockets +import websockets.exceptions +from openbb_fmp.models.websocket_connection import FmpWebSocketData +from openbb_websockets.helpers import ( + MessageQueue, + get_logger, + handle_termination_signal, + parse_kwargs, + write_to_db, +) + +queue = MessageQueue() +command_queue = MessageQueue() + + +# Create a logger instance. +logger = get_logger("openbb.websocket.fmp") +kwargs = parse_kwargs() + + +async def login(websocket, api_key): + login_event = { + "event": "login", + "data": { + "apiKey": api_key, + }, + } + try: + await websocket.send(json.dumps(login_event)) + response = await websocket.recv() + if json.loads(response).get("message") == "Unauthorized": + logger.error( + "PROVIDER ERROR: Account not authorized." + " Please check that the API key is entered correctly and is entitled to access." + ) + sys.exit(1) + msg = json.loads(response).get("message") + logger.info("PROVIDER INFO: %s", msg) + except Exception as e: + logger.error("PROVIDER ERROR: %s", e.args[0]) + sys.exit(1) + + +async def subscribe(websocket, symbol, event): + """Subscribe or unsubscribe to a symbol.""" + ticker = symbol.split(",") if isinstance(symbol, str) else symbol + subscribe_event = { + "event": event, + "data": { + "ticker": ticker, + }, + } + try: + await websocket.send(json.dumps(subscribe_event)) + except Exception as e: + msg = f"PROVIDER ERROR: {e}" + logger.error(msg) + + +async def read_stdin_and_queue_commands(): + """Read from stdin and queue commands.""" + while True: + line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) + sys.stdin.flush() + + if not line: + break + + try: + command = json.loads(line.strip()) + await command_queue.enqueue(command) + except json.JSONDecodeError: + logger.error("Invalid JSON received from stdin") + + +async def process_message(message, results_path, table_name, limit): + result = {} + message = json.loads(message) + if message.get("event") != "heartbeat": + if message.get("event") in ["login", "subscribe", "unsubscribe"]: + msg = f"PROVIDER INFO: {message.get("message")}" + logger.info(msg) + return None + try: + result = FmpWebSocketData.model_validate(message).model_dump_json( + exclude_none=True, exclude_unset=True + ) + except Exception as e: + msg = f"PROVIDER ERROR: Error validating data: {e}" + logger.error(msg) + return None + if result: + await write_to_db(result, results_path, table_name, limit) + return + + +async def connect_and_stream(url, symbol, api_key, results_path, table_name, limit): + """Connect to the WebSocket and stream data to file.""" + + handler_task = asyncio.create_task( + queue.process_queue( + lambda message: process_message(message, results_path, table_name, limit) + ) + ) + + stdin_task = asyncio.create_task(read_stdin_and_queue_commands()) + + try: + async with websockets.connect(url) as websocket: + await login(websocket, api_key) + await subscribe(websocket, symbol, "subscribe") + + while True: + ws_task = asyncio.create_task(websocket.recv()) + cmd_task = asyncio.create_task(command_queue.dequeue()) + + done, pending = await asyncio.wait( + [ws_task, cmd_task], return_when=asyncio.FIRST_COMPLETED + ) + for task in pending: + task.cancel() + + for task in done: + if task == ws_task: + message = task.result() + await queue.enqueue(message) + elif task == cmd_task: + command = task.result() + symbol = command.get("symbol") + event = command.get("event") + if symbol and event: + await subscribe(websocket, symbol, event) + + except websockets.ConnectionClosed: + logger.info("PROVIDER INFO: The WebSocket connection was closed.") + + except websockets.WebSocketException as e: + logger.error(e) + sys.exit(1) + + except Exception as e: + msg = f"PROVIDER ERROR: Unexpected error -> {e}" + logger.error(msg) + sys.exit(1) + + finally: + handler_task.cancel() + stdin_task.cancel() + await asyncio.gather(handler_task, stdin_task, return_exceptions=True) + sys.exit(0) + + +if __name__ == "__main__": + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_termination_signal, logger) + + asyncio.run_coroutine_threadsafe( + connect_and_stream( + kwargs["url"], + kwargs["symbol"], + kwargs["api_key"], + os.path.abspath(kwargs["results_file"]), + kwargs["table_name"], + kwargs.get("limit", None), + ), + loop, + ) + loop.run_forever() + + except (KeyboardInterrupt, websockets.ConnectionClosed): + logger.error("PROVIDER ERROR: WebSocket connection closed") + + except Exception as e: # pylint: disable=broad-except + msg = f"PROVIDER ERROR: {e.args[0]}" + logger.error(msg) + + finally: + sys.exit(0) diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/__init__.py b/openbb_platform/providers/tiingo/openbb_tiingo/__init__.py index 029f57a13c47..9df73581ca96 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/__init__.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/__init__.py @@ -6,6 +6,7 @@ from openbb_tiingo.models.currency_historical import TiingoCurrencyHistoricalFetcher from openbb_tiingo.models.equity_historical import TiingoEquityHistoricalFetcher from openbb_tiingo.models.trailing_dividend_yield import TiingoTrailingDivYieldFetcher +from openbb_tiingo.models.websocket_connection import TiingoWebSocketFetcher from openbb_tiingo.models.world_news import TiingoWorldNewsFetcher tiingo_provider = Provider( @@ -22,6 +23,7 @@ "CryptoHistorical": TiingoCryptoHistoricalFetcher, "CurrencyHistorical": TiingoCurrencyHistoricalFetcher, "TrailingDividendYield": TiingoTrailingDivYieldFetcher, + "WebSocketConnection": TiingoWebSocketFetcher, }, repr_name="Tiingo", ) diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py new file mode 100644 index 000000000000..7291d1cd24ce --- /dev/null +++ b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py @@ -0,0 +1,288 @@ +"""Tiingo WebSocket model.""" + +from datetime import datetime +from typing import Any, Literal, Optional + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.utils.descriptions import ( + QUERY_DESCRIPTIONS, +) +from openbb_websockets.client import WebSocketClient +from openbb_websockets.models import ( + WebSocketConnection, + WebSocketData, + WebSocketQueryParams, +) +from pydantic import Field, field_validator + +URL_MAP = { + "stock": "wss://api.tiingo.com/iex", + "fx": "wss://api.tiingo.com/fx", + "crypto": "wss://api.tiingo.com/crypto", +} + +# These are the data array order of definitions. +IEX_FIELDS = [ + "type", + "date", + "timestamp", + "symbol", + "bid_size", + "bid_price", + "mid_price", + "ask_price", + "ask_size", + "last_price", + "last_size", + "halted", + "after_hours", + "sweep_order", + "oddlot", + "nms_rule", +] +FX_FIELDS = [ + "type", + "symbol", + "date", + "bid_size", + "bid_price", + "mid_price", + "ask_price", + "ask_size", + "ask_price", +] +CRYPTO_TRADE_FIELDS = [ + "type", + "symbol", + "date", + "exchange", + "last_size", + "last_price", +] +CRYPTO_QUOTE_FIELDS = [ + "type", + "symbol", + "date", + "exchange", + "bid_size", + "bid_price", + "mid_price", + "ask_size", + "ask_price", +] + + +class TiingoWebSocketQueryParams(WebSocketQueryParams): + """Tiingo WebSocket query parameters.""" + + __json_schema_extra__ = { + "symbol": {"multiple_items_allowed": True}, + "asset_type": { + "multiple_items_allowed": False, + "choices": ["stock", "fx", "crypto"], + }, + "feed": { + "multiple_items_allowed": False, + "choices": ["trade", "trade_and_quote"], + }, + } + + symbol: str = Field( + description=QUERY_DESCRIPTIONS.get("symbol", "") + "Use '*' for all symbols.", + ) + asset_type: Literal["stock", "fx", "crypto"] = Field( + default="crypto", + description="The asset type for the feed.", + ) + feed: Literal["trade", "trade_and_quote"] = Field( + default="trade_and_quote", + description="The type of data feed to subscribe to. FX only supports quote.", + ) + + +class TiingoWebSocketData(WebSocketData): + """Tiingo WebSocket data model.""" + + timestamp: Optional[int] = Field( + default=None, + description="Nanoseconds since POSIX time UTC.", + ) + type: Literal["quote", "trade", "break"] = Field( + description="The type of data.", + ) + exchange: Optional[str] = Field( + default=None, + description="The exchange of the data. Only for crypto.", + ) + bid_size: Optional[float] = Field( + default=None, + description="The size of the bid.", + ) + bid_price: Optional[float] = Field( + default=None, + description="The price of the bid.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + mid_price: Optional[float] = Field( + default=None, + description="The mid price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_price: Optional[float] = Field( + default=None, + description="The price of the ask.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_size: Optional[float] = Field( + default=None, + description="The size of the ask.", + ) + last_price: Optional[float] = Field( + default=None, + description="The last trade price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + last_size: Optional[float] = Field( + default=None, + description="The size of the trade.", + ) + halted: Optional[bool] = Field( + default=None, + description="If the asset is halted. Only for stock.", + ) + after_hours: Optional[bool] = Field( + default=None, + description="If the data is after hours. Only for stock.", + ) + sweep_order: Optional[bool] = Field( + default=None, + description="If the order is an intermarket sweep order. Only for stock.", + ) + oddlot: Optional[bool] = Field( + default=None, + description="If the order is an oddlot. Only for stock.", + ) + nms_rule: Optional[bool] = Field( + default=None, + description="True if the order is not subject to NMS Rule 611. Only for stock.", + ) + + @field_validator("type", mode="before", check_fields=False) + def _valiidate_data_type(cls, v): + """Validate the data type.""" + return ( + "quote" if v == "Q" else "trade" if v == "T" else "break" if v == "B" else v + ) + + @field_validator("date", mode="before", check_fields=False) + def _validate_date(cls, v): + """Validate the date.""" + # pylint: disable=import-outside-toplevel + from pytz import timezone + + if isinstance(v, str): + return datetime.fromisoformat(v) + try: + return datetime.fromtimestamp(v / 1000) + except Exception: + if isinstance(v, (int, float)): + # Check if the timestamp is in nanoseconds and convert to seconds + if v > 1e12: + v = v / 1e9 # Convert nanoseconds to seconds + dt = datetime.fromtimestamp(v) + dt = timezone("America/New_York").localize(dt) + return dt + return v + + +class TiingoWebSocketConnection(WebSocketConnection): + """Tiingo WebSocket connection model.""" + + +class TiingoWebSocketFetcher( + Fetcher[TiingoWebSocketQueryParams, TiingoWebSocketConnection] +): + """Tiingo WebSocket model.""" + + @staticmethod + def transform_query(params: dict[str, Any]) -> TiingoWebSocketQueryParams: + """Transform the query parameters.""" + asset_type = params.get("asset_type") + feed = params.get("feed") + + if asset_type == "fx" and feed == "trade": + raise ValueError("FX only supports quote feed.") + + return TiingoWebSocketQueryParams(**params) + + @staticmethod + async def aextract_data( + query: TiingoWebSocketQueryParams, + credentials: Optional[dict[str, str]], + **kwargs: Any, + ) -> WebSocketClient: + """Initiailze the WebSocketClient and connect.""" + # pylint: disable=import-outside-toplevel + from asyncio import sleep + + api_key = credentials.get("tiingo_token") if credentials else "" + url = URL_MAP[query.asset_type] + threshold_level = ( + 5 + if query.asset_type == "fx" or query.feed == "trade" + else ( + 2 + if query.asset_type == "crypto" and query.feed == "trade_and_quote" + else 0 + ) + ) + + symbol = query.symbol.lower() + + kwargs = { + "url": url, + "api_key": api_key, + "threshold_level": threshold_level, + } + + client = WebSocketClient( + name=query.name, + module="openbb_tiingo.utils.websocket_client", + symbol=symbol.lower(), + limit=query.limit, + results_file=query.results_file, + table_name=query.table_name, + save_results=query.save_results, + data_model=TiingoWebSocketData, + sleep_time=query.sleep_time, + broadcast_host=query.broadcast_host, + broadcast_port=query.broadcast_port, + auth_token=query.auth_token, + **kwargs, + ) + + try: + client.connect() + # Unhandled exceptions are caught and raised as OpenBBError + except Exception as e: # pylint: disable=broad-except + client.disconnect() + raise OpenBBError(e) from e + + # Wait for the connection to be established before returning. + await sleep(2) + + if client.is_running: + return client + + client.disconnect() + raise OpenBBError("Failed to connect to the WebSocket.") + + @staticmethod + def transform_data( + data: WebSocketClient, + query: TiingoWebSocketQueryParams, + **kwargs: Any, + ) -> TiingoWebSocketConnection: + """Return the client as an instance of Data.""" + return TiingoWebSocketConnection(client=data) diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py new file mode 100644 index 000000000000..5c31928890b3 --- /dev/null +++ b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py @@ -0,0 +1,278 @@ +"""FMP WebSocket server.""" + +import asyncio +import json +import os +import signal +import sys + +import websockets +from openbb_core.provider.utils.errors import UnauthorizedError +from openbb_tiingo.models.websocket_connection import TiingoWebSocketData +from openbb_websockets.helpers import ( + MessageQueue, + get_logger, + handle_termination_signal, + parse_kwargs, + write_to_db, +) + +# These are the data array definitions. +IEX_FIELDS = [ + "type", + "date", + "timestamp", + "symbol", + "bid_size", + "bid_price", + "mid_price", + "ask_price", + "ask_size", + "last_price", + "last_size", + "halted", + "after_hours", + "sweep_order", + "oddlot", + "nms_rule", +] +FX_FIELDS = [ + "type", + "symbol", + "date", + "bid_size", + "bid_price", + "mid_price", + "ask_price", + "ask_size", + "ask_price", +] +CRYPTO_TRADE_FIELDS = [ + "type", + "symbol", + "date", + "exchange", + "last_size", + "last_price", +] +CRYPTO_QUOTE_FIELDS = [ + "type", + "symbol", + "date", + "exchange", + "bid_size", + "bid_price", + "mid_price", + "ask_size", + "ask_price", +] +subscription_id = None +queue = MessageQueue() +logger = get_logger("openbb.websocket.tiingo") +kwargs = parse_kwargs() + + +# Subscribe and unsubscribe events are handled in a separate connection using the subscription_id set by the login event. +async def update_symbols(symbol, event): + """Update the symbols to subscribe to.""" + url = kwargs["url"] + + if not subscription_id: + logger.error( + "PROVIDER ERROR: Must be assigned a subscription ID to update symbols. Try logging in." + ) + return + + update_event = { + "eventName": event, + "authorization": kwargs["api_key"], + "eventData": { + "subscriptionId": subscription_id, + "tickers": symbol, + }, + } + + async with websockets.connect(url) as websocket: + await websocket.send(json.dumps(update_event)) + response = await websocket.recv() + message = json.loads(response) + if message.get("response", {}).get("code") != 200: + logger.error(f"PROVIDER ERROR: {message}") + else: + msg = ( + f"PROVIDER INFO: {message.get('response', {}).get('message')}. " + f"Subscribed to symbols: {message.get('data', {}).get('tickers')}" + ) + logger.info(msg) + + +async def read_stdin_and_update_symbols(): + """Read from stdin and update symbols.""" + while True: + line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) + sys.stdin.flush() + + if not line: + break + + line = json.loads(line.strip()) + + if line: + symbol = line.get("symbol") + event = line.get("event") + await update_symbols(symbol, event) + + +async def process_message(message, results_path, table_name, limit): + result = {} + data_message = {} + message = json.loads(message) + msg = "" + if message.get("messageType") == "E": + response = message.get("response", {}) + msg = f"PROVIDER ERROR: {response.get('code')}: {response.get('message')}" + logger.error(msg) + sys.exit(1) + elif message.get("messageType") == "I": + response = message.get("response", {}) + + if response.get("code") != 200: + msg = ( + f"PROVIDER ERROR: {response.get('code')}: {response.get('message')}" + ) + logger.error(msg) + raise UnauthorizedError(msg) + + if response.get("code") == 200: + msg = f"PROVIDER INFO: Authorization: {response.get('message')}" + logger.info(msg) + if message.get("data", {}).get("subscriptionId"): + global subscription_id + + subscription_id = message["data"]["subscriptionId"] + + if "tickers" in message.get("data", {}): + tickers = message["data"]["tickers"] + threshold_level = message["data"].get("thresholdLevel") + msg = f"PROVIDER INFO: Subscribed to {tickers} with threshold level {threshold_level}" + logger.info(msg) + elif message.get("messageType") == "A": + data = message.get("data", []) + service = message.get("service") + if service == "iex": + data_message = {IEX_FIELDS[i]: data[i] for i in range(len(data))} + elif service == "fx": + data_message = {FX_FIELDS[i]: data[i] for i in range(len(data))} + elif service == "crypto_data": + if data[0] == "T": + data_message = { + CRYPTO_TRADE_FIELDS[i]: data[i] for i in range(len(data)) + } + elif data[0] == "Q": + data_message = { + CRYPTO_QUOTE_FIELDS[i]: data[i] for i in range(len(data)) + } + else: + return + + try: + result = TiingoWebSocketData.model_validate(data_message).model_dump_json( + exclude_none=True, exclude_unset=True + ) + except Exception as e: + msg = f"PROVIDER ERROR: Error validating data: {e}" + logger.error(msg) + return + if result: + await write_to_db(result, results_path, table_name, limit) + return + + +async def connect_and_stream( + url, symbol, threshold_level, api_key, results_path, table_name, limit +): + """Connect to the WebSocket and stream data to file.""" + + handler_task = asyncio.create_task( + queue.process_queue( + lambda message: process_message(message, results_path, table_name, limit) + ) + ) + + stdin_task = asyncio.create_task(read_stdin_and_update_symbols()) + + if isinstance(symbol, str): + ticker = symbol.lower().split(",") + + subscribe_event = { + "eventName": "subscribe", + "authorization": api_key, + "eventData": {"thresholdLevel": threshold_level, "tickers": ticker}, + } + try: + async with websockets.connect( + url, ping_interval=20, ping_timeout=20, max_queue=1000 + ) as websocket: + logger.info("PROVIDER INFO: WebSocket connection established.") + await websocket.send(json.dumps(subscribe_event)) + while True: + message = await websocket.recv() + await queue.enqueue(message) + + except websockets.ConnectionClosed as e: + msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e.reason}" + logger.info(msg) + # Attempt to reopen the connection + await asyncio.sleep(5) + await connect_and_stream( + url, symbol, threshold_level, api_key, results_path, table_name, limit + ) + + except websockets.WebSocketException as e: + logger.error(e) + sys.exit(1) + + except Exception as e: + msg = f"PROVIDER ERROR: Unexpected error -> {e}" + logger.error(msg) + sys.exit(1) + + finally: + handler_task.cancel() + await handler_task + stdin_task.cancel() + await stdin_task + sys.exit(0) + + +if __name__ == "__main__": + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_termination_signal, logger) + + asyncio.run_coroutine_threadsafe( + connect_and_stream( + kwargs["url"], + kwargs["symbol"], + kwargs["threshold_level"], + kwargs["api_key"], + os.path.abspath(kwargs["results_file"]), + kwargs["table_name"], + kwargs.get("limit", None), + ), + loop, + ) + loop.run_forever() + + except (KeyboardInterrupt, websockets.ConnectionClosed): + logger.error("PROVIDER ERROR: WebSocket connection closed") + + except Exception as e: # pylint: disable=broad-except + msg = f"PROVIDER ERROR: {e.args[0]}" + logger.error(msg) + + finally: + sys.exit(0) diff --git a/openbb_platform/pyproject.toml b/openbb_platform/pyproject.toml index f2102d1d220f..a7666d2b7823 100644 --- a/openbb_platform/pyproject.toml +++ b/openbb_platform/pyproject.toml @@ -61,6 +61,7 @@ openbb-charting = { version = "^2.2.4", optional = true } openbb-econometrics = { version = "^1.4.4", optional = true } openbb-quantitative = { version = "^1.3.4", optional = true } openbb-technical = { version = "^1.3.4", optional = true } +openbb-websockets = { version = "^1.3.4", optional = true } [tool.poetry.extras] alpha_vantage = ["openbb-alpha-vantage"] @@ -80,6 +81,7 @@ stockgrid = ["openbb-stockgrid"] technical = ["openbb-technical"] tmx = ["openbb-tmx"] tradier = ["openbb-tradier"] +websockets = ["openbb-websockets"] wsj = ["openbb-wsj"] @@ -101,6 +103,7 @@ all = [ "openbb-technical", "openbb-tmx", "openbb-tradier", + "openbb-websockets", "openbb-wsj", ] From 70c47668cf282be579270adfa3be1213d7fc00f7 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:56:00 -0800 Subject: [PATCH 02/40] setup_db as non-async --- .../websockets/openbb_websockets/client.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 3ddad5092e4c..b3df39c6dcbf 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -170,12 +170,12 @@ def _atexit(self): if os.path.exists(self.results_file): os.remove(self.results_file) - async def _setup_database(self): + def _setup_database(self): """Set up the SQLite database and table.""" # pylint: disable=import-outside-toplevel from openbb_websockets.helpers import setup_database - return await setup_database(self.results_path, self.table_name) + return setup_database(self.results_path, self.table_name) def _log_provider_output(self, output_queue): """Log output from the provider server queue.""" @@ -401,19 +401,13 @@ def results(self): def results(self): """Clear results stored from the WebSocket stream.""" # pylint: disable=import-outside-toplevel - import asyncio import sqlite3 try: with sqlite3.connect(self.results_path) as conn: conn.execute(f"DELETE FROM {self.table_name}") # noqa conn.commit() - - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - loop.run_until_complete(self._setup_database()) + self._setup_database() self.logger.info( "Results cleared from table %s in %s", self.table_name, From 7089dc70a87b5ae291bcbc137b4d408f9d9885bc Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:57:51 -0800 Subject: [PATCH 03/40] fix the other reference --- .../extensions/websockets/openbb_websockets/client.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index b3df39c6dcbf..3c81970bcc73 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -145,14 +145,7 @@ def __init__( # noqa: PLR0913 atexit.register(self._atexit) try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - try: - if loop.is_running(): - loop.create_task(self._setup_database()) - else: - asyncio.run(self._setup_database()) + self._setup_database() except DatabaseError as e: self.logger.error("Error setting up the SQLite database and table: %s", e) From 291ba508de39d7e20f97e092ea13f866e123d324 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:32:13 -0800 Subject: [PATCH 04/40] do it this way instead --- .../websockets/openbb_websockets/client.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 3c81970bcc73..fef517c2c478 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -145,7 +145,14 @@ def __init__( # noqa: PLR0913 atexit.register(self._atexit) try: - self._setup_database() + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + try: + if loop.is_running(): + loop.create_task(self._setup_database()) + else: + asyncio.run(self._setup_database()) except DatabaseError as e: self.logger.error("Error setting up the SQLite database and table: %s", e) @@ -163,12 +170,12 @@ def _atexit(self): if os.path.exists(self.results_file): os.remove(self.results_file) - def _setup_database(self): + async def _setup_database(self): """Set up the SQLite database and table.""" # pylint: disable=import-outside-toplevel from openbb_websockets.helpers import setup_database - return setup_database(self.results_path, self.table_name) + return await setup_database(self.results_path, self.table_name) def _log_provider_output(self, output_queue): """Log output from the provider server queue.""" @@ -394,13 +401,15 @@ def results(self): def results(self): """Clear results stored from the WebSocket stream.""" # pylint: disable=import-outside-toplevel + import asyncio import sqlite3 try: with sqlite3.connect(self.results_path) as conn: conn.execute(f"DELETE FROM {self.table_name}") # noqa conn.commit() - self._setup_database() + + asyncio.create_task(self._setup_database()) self.logger.info( "Results cleared from table %s in %s", self.table_name, From 5a99083e4a94e5435c3f138eb077d1639ecffcc2 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:59:08 -0800 Subject: [PATCH 05/40] deleter --- .../websockets/openbb_websockets/client.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index fef517c2c478..fa852598a81b 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -146,7 +146,7 @@ def __init__( # noqa: PLR0913 try: loop = asyncio.get_event_loop() - except RuntimeError: + except (RuntimeError, RuntimeWarning): loop = asyncio.new_event_loop() try: if loop.is_running(): @@ -401,15 +401,36 @@ def results(self): def results(self): """Clear results stored from the WebSocket stream.""" # pylint: disable=import-outside-toplevel + import sqlite3 # noqa import asyncio - import sqlite3 + import threading + + def run_in_new_loop(): + """Run setup in new event loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._setup_database()) + finally: + loop.close() + + def run_in_thread(): + """Run setup in separate thread.""" + thread = threading.Thread(target=run_in_new_loop) + thread.start() + thread.join() try: with sqlite3.connect(self.results_path) as conn: conn.execute(f"DELETE FROM {self.table_name}") # noqa conn.commit() - asyncio.create_task(self._setup_database()) + try: + loop = asyncio.get_running_loop() # noqa + run_in_thread() + except RuntimeError: + run_in_new_loop() + self.logger.info( "Results cleared from table %s in %s", self.table_name, From 0a2264d9bb1bb842ba9acbadb2bb66df399dc0ba Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:04:53 -0800 Subject: [PATCH 06/40] lint --- .../extensions/websockets/openbb_websockets/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index fa852598a81b..cc4b79e6ef28 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -112,7 +112,7 @@ def __init__( # noqa: PLR0913 self._auth_token = auth_token self._symbol = symbol self._kwargs = ( - [f"{k}={str(v).strip().replace(" ", "_")}" for k, v in kwargs.items()] + [f"{k}={str(v).strip().replace(' ', '_')}" for k, v in kwargs.items()] if kwargs else None ) From e2294604522213989c4bd4093d2f2bf4bf258b55 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:12:03 -0800 Subject: [PATCH 07/40] inner quote as single --- .../providers/fmp/openbb_fmp/utils/websocket_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py index 125dc076a365..6d50c573045d 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -86,7 +86,7 @@ async def process_message(message, results_path, table_name, limit): message = json.loads(message) if message.get("event") != "heartbeat": if message.get("event") in ["login", "subscribe", "unsubscribe"]: - msg = f"PROVIDER INFO: {message.get("message")}" + msg = f"PROVIDER INFO: {message.get('message')}" logger.info(msg) return None try: From f9d00c1d1586a99e56e0d69132d0ab18264d492d Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sat, 9 Nov 2024 00:09:35 -0800 Subject: [PATCH 08/40] add most of polygon and some general updates --- .../websockets/openbb_websockets/client.py | 8 + .../websockets/openbb_websockets/helpers.py | 7 + .../websockets/openbb_websockets/models.py | 29 +- .../fmp/openbb_fmp/utils/websocket_client.py | 7 +- .../polygon/openbb_polygon/__init__.py | 2 + .../models/websocket_connection.py | 870 ++++++++++++++++++ .../polygon/openbb_polygon/utils/constants.py | 186 ++++ .../openbb_polygon/utils/websocket_client.py | 239 +++++ 8 files changed, 1342 insertions(+), 6 deletions(-) create mode 100644 openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py create mode 100644 openbb_platform/providers/polygon/openbb_polygon/utils/constants.py create mode 100644 openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index cc4b79e6ef28..3f042a67b0a3 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -188,6 +188,14 @@ def _log_provider_output(self, output_queue): try: output = output_queue.get(timeout=1) if output: + if ( + "server rejected" in output.lower() + or "PROVIDER ERROR" in output + ): + self._stop_log_thread_event.set() + self._psutil_process.kill() + self.logger.error(output) + break output = clean_message(output) output = output + "\n" sys.stdout.write(output + "\n") diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index beb9bec5dbc4..675e43e2cc61 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -90,6 +90,7 @@ def handle_termination_signal(logger): def parse_kwargs(): """Parse command line keyword arguments.""" # pylint: disable=import-outside-toplevel + import json import sys args = sys.argv[1:].copy() @@ -97,11 +98,17 @@ def parse_kwargs(): for i, arg in enumerate(args): if "=" in arg: key, value = arg.split("=") + + if key == "connect_kwargs": + value = {} if value == "None" else json.loads(value) + _kwargs[key] = value elif arg.startswith("--"): key = arg[2:] + if i + 1 < len(args) and not args[i + 1].startswith("--"): value = args[i + 1] + if isinstance(value, str) and value.lower() in ["false", "true"]: value = value.lower() == "true" elif isinstance(value, str) and value.lower() == "none": diff --git a/openbb_platform/extensions/websockets/openbb_websockets/models.py b/openbb_platform/extensions/websockets/openbb_websockets/models.py index a631ba0cf8f9..e50833f09613 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/models.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/models.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Any, Optional +from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.provider.abstract.data import Data from openbb_core.provider.abstract.query_params import QueryParams from openbb_core.provider.utils.descriptions import ( @@ -58,11 +59,37 @@ class WebSocketQueryParams(QueryParams): description="Port to bind the broadcast server to.", ) start_broadcast: bool = Field( - default=True, + default=False, description="Whether to start the broadcast server." + " Set to False if system or network conditions do not allow it." + " Can be started manually with the 'start_broadcasting' method.", ) + connect_kwargs: Optional[Any] = Field( + default=None, + description="A formatted dictionary, or serialized JSON string, of keyword arguments to pass" + + " directly to websockets.connect().", + ) + + @field_validator("connect_kwargs", mode="before", check_fields=False) + @classmethod + def _validate_connect_kwargs(cls, v): + """Validate the connect_kwargs format.""" + # pylint: disable=import-outside-toplevel + import json + + if isinstance(v, str): + try: + v = json.loads(v) + except json.JSONDecodeError as e: + raise OpenBBError( + f"Invalid JSON format for 'connect_kwargs': {e}" + ) from e + if v is not None and not isinstance(v, dict): + raise OpenBBError( + "Invalid 'connect_kwargs' format. Must be a dictionary or serialized JSON string." + ) + + return json.dumps(v, separators=(",", ":")) class WebSocketData(Data): diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py index 6d50c573045d..bbcdf57ff871 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -17,13 +17,10 @@ write_to_db, ) -queue = MessageQueue() -command_queue = MessageQueue() - - -# Create a logger instance. logger = get_logger("openbb.websocket.fmp") kwargs = parse_kwargs() +queue = MessageQueue(max_size=kwargs.get("limit", 1000)) +command_queue = MessageQueue() async def login(websocket, api_key): diff --git a/openbb_platform/providers/polygon/openbb_polygon/__init__.py b/openbb_platform/providers/polygon/openbb_polygon/__init__.py index c8de4baf5660..1bad20eb46ca 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/__init__.py +++ b/openbb_platform/providers/polygon/openbb_polygon/__init__.py @@ -15,6 +15,7 @@ PolygonIndexHistoricalFetcher, ) from openbb_polygon.models.market_snapshots import PolygonMarketSnapshotsFetcher +from openbb_polygon.models.websocket_connection import PolygonWebSocketFetcher polygon_provider = Provider( name="polygon", @@ -38,6 +39,7 @@ "IndexHistorical": PolygonIndexHistoricalFetcher, "MarketIndices": PolygonIndexHistoricalFetcher, "MarketSnapshots": PolygonMarketSnapshotsFetcher, + "WebSocketConnection": PolygonWebSocketFetcher, }, repr_name="Polygon.io", deprecated_credentials={"API_POLYGON_KEY": "polygon_api_key"}, diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py new file mode 100644 index 000000000000..2c24d7359d7f --- /dev/null +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -0,0 +1,870 @@ +"""Polygon WebSocket Connection Model.""" + +from datetime import datetime +from typing import Any, Literal, Optional + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.provider.abstract.data import Data +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.utils.descriptions import ( + DATA_DESCRIPTIONS, + QUERY_DESCRIPTIONS, +) +from openbb_polygon.utils.constants import ( + CRYPTO_EXCHANGE_MAP, + FX_EXCHANGE_MAP, + STOCK_EXCHANGE_MAP, + STOCK_QUOTE_CONDITIONS, + STOCK_QUOTE_INDICATORS, + STOCK_TRADE_CONDITIONS, +) +from openbb_polygon.utils.helpers import map_tape +from openbb_websockets.client import WebSocketClient +from openbb_websockets.models import ( + WebSocketConnection, + WebSocketData, + WebSocketQueryParams, +) +from pydantic import ConfigDict, Field, field_validator, model_validator + +URL_MAP = { + "stock": "wss://socket.polygon.io/stocks", + "stock_delayed": "wss://delayed.polygon.io/stocks", + "fx": "wss://socket.polygon.io/forex", + "crypto": "wss://socket.polygon.io/crypto", +} + +ASSET_CHOICES = ["stock", "stock_delayed", "fx", "crypto"] + +FEED_MAP = { + "crypto": { + "aggs_min": "XA", + "aggs_sec": "XAS", + "trade": "XT", + "quote": "XQ", + "l2": "XL2", + "fmv": "FMV", + }, + "fx": { + "aggs_min": "CA", + "aggs_sec": "CAS", + "quote": "C", + "fmv": "FMV", + }, + "stock": { + "aggs_min": "AM", + "aggs_sec": "AS", + "trade": "T", + "quote": "Q", + "fmv": "FMV", + }, + "stock_delayed": { + "aggs_min": "AM", + "aggs_sec": "AS", + "trade": "T", + "quote": "Q", + "fmv": "FMV", + }, +} + + +def validate_date(cls, v): + """Validate the date.""" + # pylint: disable=import-outside-toplevel + from pytz import timezone + + try: + dt = datetime.utcfromtimestamp(v / 1000).replace(tzinfo=timezone("UTC")) + dt = dt.astimezone(timezone("America/New_York")) + return dt + except Exception: + if isinstance(v, (int, float)): + # Check if the timestamp is in nanoseconds and convert to seconds + if v > 1e12: + v = v / 1e9 # Convert nanoseconds to seconds + dt = datetime.fromtimestamp(v, tz=timezone("UTC")) + dt = dt.astimezone(timezone("America/New_York")) + return dt + + +class PolygonWebSocketQueryParams(WebSocketQueryParams): + """Polygon WebSocket query parameters.""" + + __json_schema_extra__ = { + "symbol": {"multiple_items_allowed": True}, + "asset_type": { + "multiple_items_allowed": False, + "choices": ASSET_CHOICES, + }, + } + + symbol: str = Field( + description=QUERY_DESCRIPTIONS.get("symbol", ""), + ) + asset_type: Literal["stock", "stock_delayed", "fx", "crypto"] = Field( + default="crypto", + description="The asset type associated with the symbol(s)." + + " Choose from: stock, stock_delayed, fx, crypto.", + ) + feed: Literal["aggs_min", "aggs_sec", "trade", "quote", "l2"] = Field( + default="aggs_sec", + description="The feed type to subscribe to. Choose from: aggs_min, aggs_sec, trade, quote, l2." + + "l2 is only available for crypto.", + ) + + @model_validator(mode="before") + @classmethod + def _validate_feed(cls, values): + """Validate the feed.""" + feed = values.get("feed") + asset_type = values.get("asset_type") + if asset_type == "fx" and feed in ["trade", "l2"]: + raise ValueError("FX does not support the trade or l2 feeds.") + if asset_type in ["stock", "stock_delayed"] and feed == "l2": + raise ValueError("Stock does not support the l2 feed.") + if asset_type == "index" and feed in ["trade", "quote", "l2", "fmv"]: + raise ValueError( + "Index does not support the trade, quote, l2, or fmv feeds." + ) + + return values + + +class PolygonCryptoAggsWebSocketData(WebSocketData): + """Polygon Crypto Aggregates WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "pair", + "date": "e", + "open": "o", + "high": "h", + "low": "l", + "close": "c", + "vwap": "vw", + "volume": "v", + "avg_size": "z", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description="The start of the aggregate window.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + open: float = Field( + description=DATA_DESCRIPTIONS.get("open", ""), + json_schema_extra={"x-unit_measurement": "currency"}, + ) + high: float = Field( + description=DATA_DESCRIPTIONS.get("high", ""), + json_schema_extra={"x-unit_measurement": "currency"}, + ) + low: float = Field( + description=DATA_DESCRIPTIONS.get("low", ""), + json_schema_extra={"x-unit_measurement": "currency"}, + ) + close: float = Field( + description=DATA_DESCRIPTIONS.get("close", ""), + json_schema_extra={"x-unit_measurement": "currency"}, + ) + vwap: float = Field( + description=DATA_DESCRIPTIONS.get("vwap", ""), + ) + volume: float = Field( + description=DATA_DESCRIPTIONS.get("volume", ""), + ) + avg_size: Optional[float] = Field( + default=None, + description="The average trade size for the aggregate window.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("s", None) + if values.get("z") and values["z"] == 0 or not values.get("z"): + _ = values.pop("z", None) + return values + + +class PolygonCryptoTradeWebSocketData(WebSocketData): + """Polygon Crypto WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "pair", + "date": "t", + "exchange": "x", + "price": "p", + "size": "s", + "conditions": "c", + "received_at": "r", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + received_at: datetime = Field( + description="The time the data was received by Polygon.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + exchange: str = Field( + default=None, + description="The exchange of the data.", + ) + conditions: Optional[str] = Field( + default=None, + description="The conditions of the trade. Either sellside or buyside, if available.", + ) + price: float = Field( + description="The price of the trade.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + size: float = Field( + description="The size of the trade.", + ) + + @field_validator("conditions", mode="before", check_fields=False) + @classmethod + def _validate_conditions(cls, v): + """Validate the conditions.""" + if v is None or isinstance(v, list) and v[0] == 0: + return None + elif isinstance(v, list) and v[0] == 1: + return "sellside" + elif isinstance(v, list) and v[0] == 2: + return "buyside" + return str(v) + + @field_validator("date", "received_at", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return CRYPTO_EXCHANGE_MAP.get(v, str(v)) + + @model_validator(mode="before") + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("i", None) + return values + + +class PolygonCryptoQuoteWebSocketData(WebSocketData): + """Polygon Crypto Quote WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "pair", + "date": "t", + "exchange": "x", + "bid": "bp", + "bid_size": "bs", + "ask": "ap", + "ask_size": "as", + "received_at": "r", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + received_at: datetime = Field( + description="The time the data was received by Polygon.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + exchange: str = Field( + default=None, + description="The exchange of the data.", + ) + bid_size: float = Field( + description="The size of the bid.", + ) + bid: float = Field( + description="The bid price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask: float = Field( + description="The ask price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_size: float = Field( + description="The size of the ask.", + ) + + @field_validator("date", "received_at", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return CRYPTO_EXCHANGE_MAP.get(v, str(v)) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + lp = values.pop("lp", None) + ls = values.pop("ls", None) + if lp: + values["last_price"] = lp + if ls: + values["last_size"] = ls + + return values + + +class PolygonCryptoL2WebSocketData(WebSocketData): + """Polygon Crypto L2 WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "pair", + "date": "t", + "exchange": "x", + "bid": "b", + "ask": "a", + "received_at": "r", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + received_at: datetime = Field( + description="The time the data was received by Polygon.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + exchange: str = Field( + default=None, + description="The exchange of the data.", + ) + bid: list[list[float]] = Field( + description="An array of bid prices, where each entry contains two elements:" + + " the first is the bid price, and the second is the size, with a maximum depth of 100.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask: list[list[float]] = Field( + description="An array of ask prices, where each entry contains two elements:" + + " the first is the ask price, and the second is the size, with a maximum depth of 100.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + + @field_validator("date", "received_at", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return CRYPTO_EXCHANGE_MAP.get(v, str(v)) + + +class PolygonFXQuoteWebSocketData(WebSocketData): + """Polygon FX Quote WebSocket data model.""" + + __alias_dict__ = { + "date": "t", + "type": "ev", + "symbol": "p", + "exchange": "x", + "ask": "a", + "bid": "b", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + exchange: str = Field( + default=None, + description="The exchange of the data.", + ) + bid: float = Field( + description="The bid price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask: float = Field( + description="The ask price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return FX_EXCHANGE_MAP.get(v, str(v)) + + +class PolygonStockAggsWebSocketData(WebSocketData): + """Polygon Stock Aggregates WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "e", + "day_open": "op", + "day_volume": "av", + "open": "o", + "high": "h", + "low": "l", + "close": "c", + "vwap": "vw", + "day_vwap": "a", + "volume": "v", + "avg_size": "z", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description="The start of the aggregate window.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + day_open: float = Field( + description="Today's official opening price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + open: float = Field( + description=DATA_DESCRIPTIONS.get("open", "") + + " For the current aggregate window.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + high: float = Field( + description=DATA_DESCRIPTIONS.get("high", "") + + " For the current aggregate window.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + low: float = Field( + description=DATA_DESCRIPTIONS.get("low", "") + + " For the current aggregate window.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + close: float = Field( + description=DATA_DESCRIPTIONS.get("close", "") + + " For the current aggregate window.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + vwap: float = Field( + description=DATA_DESCRIPTIONS.get("vwap", "") + + " For the current aggregate window.", + ) + day_vwap: float = Field( + description="Today's volume weighted average price.", + ) + volume: float = Field( + description=DATA_DESCRIPTIONS.get("volume", "") + + " For the current aggregate window.", + ) + day_volume: float = Field( + description="Today's accumulated volume.", + ) + avg_size: Optional[float] = Field( + default=None, + description="The average trade size for the aggregate window.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("s", None) + _ = values.pop("otc", None) + if values.get("z") and values["z"] == 0 or not values.get("z"): + _ = values.pop("z", None) + return values + + +class PolygonStockTradeWebSocketData(WebSocketData): + """Polygon Stock Trade WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "t", + "exchange": "x", + "trf_id": "trfi", + "tape": "z", + "price": "p", + "conditions": "c", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description="The start of the aggregate window.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + price: float = Field( + description="The price of the trade.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + exchange: str = Field( + description="The exchange where the trade originated.", + ) + tape: str = Field( + description="The tape where the trade occurred.", + ) + conditions: Optional[str] = Field( + default=None, + description="The conditions of the trade.", + ) + trf_id: Optional[str] = Field( + default=None, + description="The ID for the Trade Reporting Facility where the trade took place.", + ) + trf_timestamp: Optional[datetime] = Field( + default=None, + description="The timestamp of when the trade reporting facility received this trade.", + ) + + @field_validator("date", "trf_timestamp", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("tape", mode="before", check_fields=False) + @classmethod + def _validate_tape(cls, v): + """Validate the tape.""" + return map_tape(v) + + @field_validator("exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return STOCK_EXCHANGE_MAP.get(v, str(v)) + + @field_validator("conditions", mode="before", check_fields=False) + @classmethod + def _validate_conditions(cls, v): + """Validate the conditions.""" + if v is None or not v: + return None + new_conditions: list = [] + if isinstance(v, list): + for c in v: + new_conditions.append(STOCK_TRADE_CONDITIONS.get(c, str(c))) + elif isinstance(v, int): + new_conditions.append(STOCK_TRADE_CONDITIONS.get(v, str(v))) + + if not new_conditions: + return None + return "; ".join(new_conditions) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("i", None) + _ = values.pop("q", None) + return values + + +class PolygonStockQuoteWebSocketData(WebSocketData): + """Polygon Stock Quote WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "t", + "bid_exchange": "bx", + "bid_size": "bs", + "bid": "bp", + "ask": "ap", + "ask_size": "as", + "ask_exchange": "ax", + "tape": "z", + "condition": "c", + "indicators": "i", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description="The start of the aggregate window.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + bid_exchange: str = Field( + description="The exchange where the bid originated.", + ) + bid_size: float = Field( + description="The size of the bid.", + ) + bid: float = Field( + description="The bid price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask: float = Field( + description="The ask price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_size: float = Field( + description="The size of the ask.", + ) + ask_exchange: str = Field( + description="The exchange where the ask originated.", + ) + tape: str = Field( + description="The tape where the quote occurred.", + ) + condition: Optional[str] = Field( + default=None, + description="The condition of the quote.", + ) + indicators: Optional[str] = Field( + default=None, + description="The indicators of the quote.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("bid_exchange", "ask_exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return STOCK_EXCHANGE_MAP.get(v, str(v)) + + @field_validator("tape", mode="before", check_fields=False) + @classmethod + def _validate_tape(cls, v): + """Validate the tape.""" + return map_tape(v) + + @field_validator("condition", mode="before", check_fields=False) + @classmethod + def _validate_condition(cls, v): + """Validate the condition.""" + return STOCK_QUOTE_CONDITIONS.get(v, str(v)) + + @field_validator("indicators", mode="before", check_fields=False) + @classmethod + def _validate_indicators(cls, v): + """Validate the indicators.""" + if v is None or not v: + return None + new_indicators: list = [] + if isinstance(v, list): + for c in v: + new_indicators.append(STOCK_QUOTE_INDICATORS.get(c, str(c))) + elif isinstance(v, int): + new_indicators.append(STOCK_QUOTE_INDICATORS.get(v, str(v))) + + if not new_indicators: + return None + return "; ".join(new_indicators) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("q", None) + return values + + +class PolygonFairMarketValueData(WebSocketData): + """Polygon Fair Market Value WebSocket Data.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "t", + "fair_market_value": "fmv", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + fair_market_value: float = Field( + description="Polygon proprietary algorithm determining real-time, accurate," + + " fair market value of a tradable security.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + + +MODEL_MAP = { + "XT": PolygonCryptoTradeWebSocketData, + "XQ": PolygonCryptoQuoteWebSocketData, + "XL2": PolygonCryptoL2WebSocketData, + "XA": PolygonCryptoAggsWebSocketData, + "XAS": PolygonCryptoAggsWebSocketData, + "FMV": PolygonFairMarketValueData, + "CA": PolygonCryptoAggsWebSocketData, + "CAS": PolygonCryptoAggsWebSocketData, + "C": PolygonFXQuoteWebSocketData, + "AM": PolygonStockAggsWebSocketData, + "AS": PolygonStockAggsWebSocketData, + "T": PolygonStockTradeWebSocketData, + "Q": PolygonStockQuoteWebSocketData, +} + + +class PolygonWebSocketData(Data): + """Polygon WebSocket data model.""" + + # model_config = ConfigDict( + # extra="allow", + # populate_by_alias=True, + # arbitrary_types_allowed=True, + # validate_default=False, + # frozen=False, + # strict=False, + # ) + + def __new__(cls, **data): + """Create new instance of appropriate model type.""" + model = MODEL_MAP.get(data.get("ev")) or MODEL_MAP.get(data.get("type")) + if not model: + return super().__new__(cls) + + return model.model_validate(data) + + +class PolygonWebSocketConnection(WebSocketConnection): + """Polygon WebSocket connection model.""" + + +class PolygonWebSocketFetcher( + Fetcher[PolygonWebSocketQueryParams, PolygonWebSocketConnection] +): + """Polygon WebSocket Fetcher.""" + + @staticmethod + def transform_query(params: dict[str, Any]) -> PolygonWebSocketQueryParams: + """Transform the query parameters.""" + return PolygonWebSocketQueryParams(**params) + + @staticmethod + def extract_data( + query: PolygonWebSocketQueryParams, + credentials: Optional[dict[str, str]], + **kwargs: Any, + ) -> WebSocketClient: + """Extract data from the WebSocket.""" + # pylint: disable=import-outside-toplevel + import time + + api_key = credentials.get("polygon_api_key") if credentials else "" + url = URL_MAP[query.asset_type] + + symbol = query.symbol.upper() + + kwargs = { + "url": url, + "asset_type": query.asset_type, + "feed": query.feed, + "api_key": api_key, + "connect_kwargs": query.connect_kwargs, + } + + client = WebSocketClient( + name=query.name, + module="openbb_polygon.utils.websocket_client", + symbol=symbol, + limit=query.limit, + results_file=query.results_file, + table_name=query.table_name, + save_results=query.save_results, + data_model=PolygonWebSocketData, + sleep_time=query.sleep_time, + broadcast_host=query.broadcast_host, + broadcast_port=query.broadcast_port, + auth_token=query.auth_token, + **kwargs, + ) + + try: + client.connect() + + except Exception as e: # pylint: disable=broad-except + client.disconnect() + raise OpenBBError(e) from e + + time.sleep(1) + + if client.is_running: + return client + + raise OpenBBError("Failed to connect to the WebSocket.") + + @staticmethod + def transform_data( + data: WebSocketClient, + query: PolygonWebSocketQueryParams, + **kwargs: Any, + ) -> PolygonWebSocketConnection: + """Return the client as an instance of Data.""" + return PolygonWebSocketConnection(client=data) diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py new file mode 100644 index 000000000000..4922e6eaf5fc --- /dev/null +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py @@ -0,0 +1,186 @@ +"""Polygon Constants.""" + +CRYPTO_EXCHANGE_MAP = { + 1: "Coinbase", + 2: "Bitfinex", + 6: "Bitstamp", + 10: "Binance", + 23: "Kraken", +} + +FX_EXCHANGE_MAP = { + 48: "Currency Banks 1", +} + +STOCK_EXCHANGE_MAP = { + 1: "XNYS", + 2: "XNAS", + 3: "XNYS", + 4: "FINR", + 5: "XNAS", + 6: "XNAS", + 7: "XCBO", + 8: "XCBO", + 9: "XNYS", + 10: "XNYS", + 11: "XNYS", + 12: "XNAS", + 13: "XNYS", + 14: "LTSE", + 15: "IEXG", + 16: "XCBO", + 17: "XNAS", + 18: "XCBO", + 19: "XCBO", + 20: "MIHI", + 21: "MEMX", + 62: "FINR", +} + +STOCK_TRADE_CONDITIONS = { + 0: "Regular Trade", + 1: "Acquisition", + 2: "Average Price Trade", + 3: "Automatic Execution", + 4: "Bunched Trade", + 5: "Bunched Sold Trade", + 6: "CAP Election", + 7: "Cash Sale", + 8: "Closing Prints", + 9: "Cross Trade", + 10: "Derivatively Priced", + 11: "Distribution", + 12: "Form T/Extended Hours", + 13: "Extended Hours (Sold Out Of Sequence)", + 14: "Intermarket Sweep", + 15: "Market Center Official Close", + 16: "Market Center Official Open", + 17: "Market Center Opening Trade", + 18: "Market Center Reopening Trade", + 19: "Market Center Closing Trade", + 20: "Next Day", + 21: "Price Variation Trade", + 22: "Prior Reference Price", + 23: "Rule 155 Trade (AMEX)", + 24: "Rule 127 (NYSE Only)", + 25: "Opening Prints", + 27: "Stopped Stock (Regular Trade)", + 28: "Re-Opening Prints", + 29: "Seller", + 30: "Sold Last", + 31: "Sold Last and Stopped Stock", + 32: "Sold (Out Of Sequence)", + 33: "Sold (Out of Sequence) and Stopped Stock", + 34: "Split Trade", + 35: "Stock Option", + 36: "Yellow Flag Regular Trade", + 37: "Odd Lot Trade", + 38: "Corrected Consolidated Close (per listing market)", + 41: "Trade Thru Exempt", + 52: "Contingent Trade", + 53: "Qualified Contingent Trade", + 55: "Opening Reopening Trade Detail", + 57: "Short Sale Restriction Activated", + 58: "Short Sale Restriction Continued", + 59: "Short Sale Restriction Deactivated", + 60: "Short Sale Restriction In Effect", + 62: "Financial Status - Bankrupt", + 63: "Financial Status - Deficient", + 64: "Financial Status - Delinquent", + 65: "Financial Status - Bankrupt and Deficient", + 66: "Financial Status - Bankrupt and Delinquent", + 67: "Financial Status - Deficient and Delinquent", + 68: "Financial Status - Deficient, Delinquent, and Bankrupt", + 69: "Financial Status - Liquidation", + 70: "Financial Status - Creations Suspended", + 71: "Financial Status - Redemptions Suspended", +} + +STOCK_QUOTE_CONDITIONS = { + 0: "Regular", + 1: "RegularTwoSidedOpen", + 2: "RegularOneSidedOpen", + 3: "SlowAsk", + 4: "SlowBid", + 5: "SlowBidAsk", + 6: "SlowDueLRPBid", + 7: "SlowDueLRPAsk", + 8: "SlowDueNYSELRP", + 9: "SlowDueSetSlowListBidAsk", + 10: "ManualAskAutomatedBid", + 11: "ManualBidAutomatedAsk", + 12: "ManualBidAndAsk", + 13: "Opening", + 14: "Closing", + 15: "Closed", + 16: "Resume", + 17: "FastTrading", + 18: "TradingRangeIndication", + 19: "MarketMakerQuotesClosed", + 20: "NonFirm", + 21: "NewsDissemination", + 22: "OrderInflux", + 23: "OrderImbalance", + 24: "DueToRelatedSecurityNewsDissemination", + 25: "DueToRelatedSecurityNewsPending", + 26: "AdditionalInformation", + 27: "NewsPending", + 28: "AdditionalInformationDueToRelatedSecurity", + 29: "DueToRelatedSecurity", + 30: "InViewOfCommon", + 31: "EquipmentChangeover", + 32: "NoOpenNoResponse", + 33: "SubPennyTrading", + 34: "AutomatedBidNoOfferNoBid", + 35: "LULDPriceBand", + 36: "MarketWideCircuitBreakerLevel1", + 37: "MarketWideCircuitBreakerLevel2", + 38: "MarketWideCircuitBreakerLevel3", + 39: "RepublishedLULDPriceBand", + 40: "OnDemandAuction", + 41: "CashOnlySettlement", + 42: "NextDaySettlement", + 43: "LULDTradingPause", + 71: "SlowDuelRPBidAsk", + 80: "Cancel", + 81: "Corrected Price", + 82: "SIPGenerated", + 83: "Unknown", + 84: "Crossed Market", + 85: "Locked Market", + 86: "Depth On Offer Side", + 87: "Depth On Bid Side", + 88: "Depth On Bid And Offer", + 89: "Pre Opening Indication", + 90: "Syndicate Bid", + 91: "Pre Syndicate Bid", + 92: "Penalty Bid", +} + +STOCK_QUOTE_INDICATORS = { + 601: "NBBO_NO_CHANGE", + 602: "NBBO_QUOTE_IS_NBBO", + 603: "NBBO_NO_BB_NO_BO", + 604: "NBBO_BB_BO_SHORT_APPENDAGE", + 605: "NBBO_BB_BO_LONG_APPENDAGE", + 621: "HELD_TRADE_NOT_LAST_SALE_AND_NOT_ON_CONSOLIDATED", + 622: "HELD_TRADE_LAST_SALE_BUT_NOT_ON_CONSOLIDATED", + 623: "HELD_TRADE_LAST_SALE_AND_ON_CONSOLIDATED", + 501: "RETAIL_INTEREST_ON_BID", + 502: "RETAIL_INTEREST_ON_ASK", + 503: "RETAIL_INTEREST_ON_BID_AND_ASK", + 504: "FINRA_BBO_NO_CHANGE", + 505: "FINRA_BBO_DOES_NOT_EXIST", + 506: "FINRA_BB_BO_EXECUTABLE", + 507: "FINRA_BB_BELOW_LOWER_BAND", + 508: "FINRA_BO_ABOVE_UPPER_BAND", + 509: "FINRA_BB_BELOW_LOWER_BAND_BO_ABOVE_UPPER_BAND", + 901: "CTA_NOT_DUE_TO_RELATED_SECURITY", + 902: "CTA_DUE_TO_RELATED_SECURITY", + 903: "CTA_NOT_IN_VIEW_OF_COMMON", + 904: "CTA_IN_VIEW_OF_COMMON", + 905: "CTA_PRICE_INDICATOR", + 906: "CTA_NEW_PRICE_INDICATOR", + 907: "CTA_CORRECTED_PRICE_INDICATION", + 908: "CTA_CANCELLED_MARKET_IMBALANCE_PRICE_TRADING_RANGE_INDICATION", +} diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py new file mode 100644 index 000000000000..993b86eb6e62 --- /dev/null +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -0,0 +1,239 @@ +"""Polygon WebSocket server.""" + +import asyncio +import json +import os +import signal +import sys + +import websockets +import websockets.exceptions +from openbb_polygon.models.websocket_connection import ( + FEED_MAP, + PolygonWebSocketData, +) +from openbb_websockets.helpers import ( + MessageQueue, + get_logger, + handle_termination_signal, + parse_kwargs, + write_to_db, +) + +logger = get_logger("openbb.websocket.polygon") +queue = MessageQueue() +command_queue = MessageQueue() + +kwargs = parse_kwargs() +CONNECT_KWARGS = kwargs.pop("connect_kwargs", {}) +FEED = kwargs.pop("feed", None) +ASSET_TYPE = kwargs.pop("asset_type", None) + + +async def handle_symbol(symbol): + """Handle the symbol and map it to the correct format.""" + symbols = symbol.split(",") if isinstance(symbol, str) else symbol + new_symbols: list = [] + feed = FEED_MAP.get(ASSET_TYPE, {}).get(FEED) + for s in symbols: + if s == "*": + new_symbols.append(f"{feed}.*") + continue + ticker = s.upper() + if ASSET_TYPE == "crypto" and "-" not in ticker and ticker != "*": + ticker = ticker[:3] + "-" + ticker[3:] + elif ASSET_TYPE == "fx" and "/" not in ticker and ticker != "*": + ticker = ticker[:3] + "/" + ticker[3:] + elif ASSET_TYPE == "fx" and "-" in ticker: + ticker = ticker.replace("-", "/") + + if ticker and "." not in ticker and not ticker.startswith(feed): + ticker = f"{feed}.{ticker}" + new_symbols.append(ticker) + + return ",".join(new_symbols) + + +async def login(websocket, api_key): + login_event = f'{{"action":"auth","params":"{api_key}"}}' + try: + await websocket.send(login_event) + res = await websocket.recv() + response = json.loads(res) + messages = response if isinstance(response, list) else [response] + for msg in messages: + if msg.get("status") == "connected": + logger.info("PROVIDER INFO: %s", msg.get("message")) + continue + if msg.get("status") != "auth_success": + err = f"PROVIDER ERROR: {msg.get('status')} -> {msg.get('message')}" + logger.error(err) + sys.exit(1) + logger.info("PROVIDER INFO: %s", msg.get("message")) + except Exception as e: + logger.error("PROVIDER ERROR: %s", e.args[0]) + sys.exit(1) + + +async def subscribe(websocket, symbol, event): + """Subscribe or unsubscribe to a symbol.""" + ticker = await handle_symbol(symbol) + subscribe_event = f'{{"action":"{event}","params":"{ticker}"}}' + try: + await websocket.send(subscribe_event) + except Exception as e: + msg = f"PROVIDER ERROR: {e}" + logger.error(msg) + + +async def read_stdin_and_queue_commands(): + """Read from stdin and queue commands.""" + while True: + line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) + sys.stdin.flush() + + if not line: + break + + try: + command = json.loads(line.strip()) + await command_queue.enqueue(command) + except json.JSONDecodeError: + logger.error("Invalid JSON received from stdin") + + +async def process_message(message, results_path, table_name, limit): + """Process the WebSocket message.""" + messages = message if isinstance(message, list) else [message] + for msg in messages: + if "status" in msg or "message" in msg: + if "status" in msg and msg["status"] == "error": + err = msg.get("message") + raise websockets.WebSocketException(err) + if "message" in msg and msg.get("message"): + logger.info("PROVIDER INFO: %s", msg.get("message")) + elif msg and "ev" in msg and "status" not in msg: + try: + result = PolygonWebSocketData(**msg).model_dump_json( + exclude_none=True, exclude_unset=True + ) + except Exception as e: + err = f"PROVIDER ERROR: Error validating data: {e}" + logger.error(err) + return None + if result: + await write_to_db(result, results_path, table_name, limit) + + +async def connect_and_stream(url, symbol, api_key, results_path, table_name, limit): + """Connect to the WebSocket and stream data to file.""" + + handler_task = asyncio.create_task( + queue.process_queue( + lambda message: process_message(message, results_path, table_name, limit) + ) + ) + + stdin_task = asyncio.create_task(read_stdin_and_queue_commands()) + + try: + connect_kwargs = CONNECT_KWARGS.copy() + if "ping_timeout" not in connect_kwargs: + connect_kwargs["ping_timeout"] = None + if "close_timeout" not in connect_kwargs: + connect_kwargs["close_timeout"] = None + + try: + async with websockets.connect(url, **connect_kwargs) as websocket: + await login(websocket, api_key) + response = await websocket.recv() + messages = json.loads(response) + await process_message(messages, results_path, table_name, limit) + await subscribe(websocket, symbol, "subscribe") + response = await websocket.recv() + messages = json.loads(response) + await process_message(messages, results_path, table_name, limit) + while True: + ws_task = asyncio.create_task(websocket.recv()) + cmd_task = asyncio.create_task(command_queue.dequeue()) + + done, pending = await asyncio.wait( + [ws_task, cmd_task], return_when=asyncio.FIRST_COMPLETED + ) + for task in pending: + task.cancel() + + for task in done: + if task == ws_task: + messages = task.result() + await asyncio.shield(queue.enqueue(json.loads(messages))) + elif task == cmd_task: + command = task.result() + symbol = command.get("symbol") + event = command.get("event") + if symbol and event: + await subscribe(websocket, symbol, event) + except websockets.InvalidStatusCode as e: + if e.status_code == 404: + msg = f"PROVIDER ERROR: {e}" + logger.error(msg) + sys.exit(1) + else: + raise + + except websockets.ConnectionClosed as e: + msg = f"PROVIDER INFO: The WebSocket connection was closed -> {str(e)}" + logger.info(msg) + # Attempt to reopen the connection + logger.info("PROVIDER INFO: Attempting to reconnect after five seconds.") + await asyncio.sleep(5) + await connect_and_stream(url, symbol, api_key, results_path, table_name, limit) + + except websockets.WebSocketException as e: + msg = f"PROVIDER ERROR: WebSocketException -> {e}" + logger.error(msg) + sys.exit(1) + + except Exception as e: + msg = f"PROVIDER ERROR: Unexpected error -> {e}" + logger.error(msg) + sys.exit(1) + + finally: + handler_task.cancel() + stdin_task.cancel() + await asyncio.gather(handler_task, stdin_task, return_exceptions=True) + sys.exit(0) + + +if __name__ == "__main__": + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.set_exception_handler(lambda loop, context: None) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_termination_signal, logger) + + asyncio.run_coroutine_threadsafe( + connect_and_stream( + kwargs["url"], + kwargs["symbol"], + kwargs["api_key"], + os.path.abspath(kwargs["results_file"]), + kwargs["table_name"], + kwargs.get("limit", None), + ), + loop, + ) + loop.run_forever() + + except (KeyboardInterrupt, websockets.ConnectionClosed): + logger.error("PROVIDER ERROR: WebSocket connection closed") + + except Exception as e: # pylint: disable=broad-except + msg = f"PROVIDER ERROR: {e.args[0]}" + logger.error(msg) + + finally: + sys.exit(0) From 2c317a346bc14e8ef847eced871e5b8441adc098 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sat, 9 Nov 2024 00:10:04 -0800 Subject: [PATCH 09/40] didn't add that file --- .../openbb_polygon/models/websocket_connection.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 2c24d7359d7f..5972f42cea6b 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -775,15 +775,6 @@ class PolygonFairMarketValueData(WebSocketData): class PolygonWebSocketData(Data): """Polygon WebSocket data model.""" - # model_config = ConfigDict( - # extra="allow", - # populate_by_alias=True, - # arbitrary_types_allowed=True, - # validate_default=False, - # frozen=False, - # strict=False, - # ) - def __new__(cls, **data): """Create new instance of appropriate model type.""" model = MODEL_MAP.get(data.get("ev")) or MODEL_MAP.get(data.get("type")) From b53a636f43ee882150f653e11a0a4e11769fccb5 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:44:19 -0800 Subject: [PATCH 10/40] add some exception handling --- .../websockets/openbb_websockets/client.py | 92 ++++++++++--------- .../websockets/openbb_websockets/models.py | 44 +++++++++ .../openbb_websockets/websockets_router.py | 11 ++- .../models/websocket_connection.py | 8 +- .../openbb_polygon/utils/websocket_client.py | 2 +- 5 files changed, 105 insertions(+), 52 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 3f042a67b0a3..ff909bbb0319 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -3,7 +3,7 @@ # pylint: disable=too-many-statements # flake8: noqa: PLR0915 import logging -from typing import TYPE_CHECKING, Literal, Optional +from typing import TYPE_CHECKING, Literal, Optional, Union if TYPE_CHECKING: from openbb_core.provider.abstract.data import Data @@ -23,7 +23,7 @@ class WebSocketClient: The symbol(s) requested to subscribe. Enter multiple symbols separated by commas without spaces. limit : Optional[int] The limit of records to hold in memory. Once the limit is reached, the oldest records are removed. - Default is None. + Default is 300. Set to None to keep all records. results_file : Optional[str] Absolute path to the file for continuous writing. By default, a temporary file is created. table_name : Optional[str] @@ -37,9 +37,11 @@ class WebSocketClient: The authentication token to use for the WebSocket connection. Default is None. Only used for API and Python application endpoints. logger : Optional[logging.Logger] - The logger instance to use this connection. By default, a new logger is created. - kwargs : dict - Additional keyword arguments to pass to the target module. + The pre-configured logger instance to use for this connection. By default, a new logger is created. + kwargs : Optional[dict] + Additional keyword arguments to pass to the target provider module. Keywords and values must not contain spaces. + To pass items to 'websocket.connect()', include them in the 'kwargs' dictionary as, + {'connect_kwargs': {'key': 'value'}}. Properties ---------- @@ -48,9 +50,9 @@ class WebSocketClient: module : str Path to the provider connection script. is_running : bool - Check if the provider connection is running. + Check if the provider connection process is running. is_broadcasting : bool - Check if the broadcast server is running. + Check if the broadcast server process is running. broadcast_address : str URI address for the results broadcast server. results : list @@ -83,7 +85,7 @@ def __init__( # noqa: PLR0913 name: str, module: str, symbol: Optional[str] = None, - limit: Optional[int] = None, + limit: Optional[int] = 300, results_file: Optional[str] = None, table_name: Optional[str] = None, save_results: bool = False, @@ -91,7 +93,7 @@ def __init__( # noqa: PLR0913 auth_token: Optional[str] = None, logger: Optional[logging.Logger] = None, **kwargs, - ): + ) -> None: """Initialize the WebSocketClient class.""" # pylint: disable=import-outside-toplevel import asyncio # noqa @@ -130,6 +132,7 @@ def __init__( # noqa: PLR0913 self._broadcast_thread = None self._broadcast_log_thread = None self._broadcast_message_queue = Queue() + self._exception = None if not results_file: with tempfile.NamedTemporaryFile(delete=False) as temp_file: @@ -144,6 +147,8 @@ def __init__( # noqa: PLR0913 atexit.register(self._atexit) + # Set up the SQLite database and table. + # Loop handling is for when the class is used directly instead of from the app or API. try: loop = asyncio.get_event_loop() except (RuntimeError, RuntimeWarning): @@ -156,7 +161,7 @@ def __init__( # noqa: PLR0913 except DatabaseError as e: self.logger.error("Error setting up the SQLite database and table: %s", e) - def _atexit(self): + def _atexit(self) -> None: """Clean up the WebSocket client processes at exit.""" # pylint: disable=import-outside-toplevel import os @@ -170,14 +175,14 @@ def _atexit(self): if os.path.exists(self.results_file): os.remove(self.results_file) - async def _setup_database(self): + async def _setup_database(self) -> None: """Set up the SQLite database and table.""" # pylint: disable=import-outside-toplevel from openbb_websockets.helpers import setup_database return await setup_database(self.results_path, self.table_name) - def _log_provider_output(self, output_queue): + def _log_provider_output(self, output_queue) -> None: """Log output from the provider server queue.""" # pylint: disable=import-outside-toplevel import queue # noqa @@ -191,11 +196,13 @@ def _log_provider_output(self, output_queue): if ( "server rejected" in output.lower() or "PROVIDER ERROR" in output + or "Unexpected error" in output ): - self._stop_log_thread_event.set() - self._psutil_process.kill() + err = ChildProcessError(output) + self._exception = err self.logger.error(output) break + output = clean_message(output) output = output + "\n" sys.stdout.write(output + "\n") @@ -203,7 +210,7 @@ def _log_provider_output(self, output_queue): except queue.Empty: continue - def _log_broadcast_output(self, output_queue): + def _log_broadcast_output(self, output_queue) -> None: """Log output from the broadcast server queue.""" # pylint: disable=import-outside-toplevel import queue # noqa @@ -245,7 +252,7 @@ def _log_broadcast_output(self, output_queue): except queue.Empty: continue - def connect(self): + def connect(self) -> None: """Connect to the provider WebSocket.""" # pylint: disable=import-outside-toplevel import json # noqa @@ -313,7 +320,7 @@ def connect(self): def send_message( self, message, target: Literal["provider", "broadcast"] = "provider" - ): + ) -> None: """Send a message to the WebSocket process.""" if target == "provider": self._provider_message_queue.put(message) @@ -322,7 +329,7 @@ def send_message( self._broadcast_message_queue.put(message) read_message_queue(self, self._broadcast_message_queue, target="broadcast") - def disconnect(self): + def disconnect(self) -> None: """Disconnect from the provider WebSocket.""" self._stop_log_thread_event.set() if self._process is None or self.is_running is False: @@ -339,9 +346,11 @@ def disconnect(self): self._log_thread.join() self._stop_log_thread_event.clear() self.logger.info("Disconnected from the provider WebSocket.") + if hasattr(self, "_exception") and self._exception: + raise self._exception return - def subscribe(self, symbol): + def subscribe(self, symbol) -> None: """Subscribe to a new symbol or list of symbols.""" # pylint: disable=import-outside-toplevel import json @@ -353,7 +362,7 @@ def subscribe(self, symbol): new_symbols = list(set(old_symbols + ticker)) self._symbol = ",".join(new_symbols) - def unsubscribe(self, symbol): + def unsubscribe(self, symbol) -> None: """Unsubscribe from a symbol or list of symbols.""" # pylint: disable=import-outside-toplevel import json @@ -370,22 +379,22 @@ def unsubscribe(self, symbol): self._symbol = ",".join(new_symbols) @property - def is_running(self): + def is_running(self) -> bool: """Check if the provider connection is running.""" if hasattr(self._psutil_process, "is_running"): return self._psutil_process.is_running() return False @property - def is_broadcasting(self): + def is_broadcasting(self) -> bool: """Check if the broadcast server is running.""" if hasattr(self._psutil_broadcast_process, "is_running"): return self._psutil_broadcast_process.is_running() return False @property - def results(self): - """Retrieve the raw results dumped by the WebSocket stream.""" + def results(self) -> Union[list[dict], None]: + """Retrieve the deserialized results from the results file.""" # pylint: disable=import-outside-toplevel import json # noqa import sqlite3 @@ -397,13 +406,14 @@ def results(self): cursor = conn.execute(f"SELECT * FROM {self.table_name}") # noqa for row in cursor: index, message = row - output.append(json.loads(message)) + output.append(json.loads(json.loads(message))) + if output: return output self.logger.info("No results found in %s", self.results_file) - return [] + return @results.deleter def results(self): @@ -448,7 +458,7 @@ def run_in_thread(): self.logger.error("Error clearing results: %s", e) @property - def module(self): + def module(self) -> str: """Path to the provider connection script.""" return self._module @@ -465,12 +475,12 @@ def module(self, module): ] @property - def symbol(self): + def symbol(self) -> str: """Symbol(s) requested to subscribe.""" return self._symbol @property - def limit(self): + def limit(self) -> Union[int, None]: """Get the limit of records to hold in memory.""" return self._limit @@ -480,7 +490,7 @@ def limit(self, limit): self._limit = limit @property - def broadcast_address(self): + def broadcast_address(self) -> Union[str, None]: """Get the WebSocket broadcast address.""" return ( self._broadcast_address @@ -493,7 +503,7 @@ def start_broadcasting( host: str = "127.0.0.1", port: int = 6666, **kwargs, - ): + ) -> None: """Broadcast results over a network connection.""" # pylint: disable=import-outside-toplevel import os # noqa @@ -594,15 +604,11 @@ def stop_broadcasting(self): return @property - def transformed_results(self): - """Deserialize the records from the results file.""" - # pylint: disable=import-outside-toplevel - import json - + def transformed_results(self) -> list["Data"]: + """Model validated records from the results file.""" if not self.data_model: raise NotImplementedError("No model provided to transform the results.") - - return [self.data_model.model_validate(json.loads(d)) for d in self.results] + return [self.data_model.model_validate(d) for d in self.results] def __repr__(self): """Return the WebSocketClient representation.""" @@ -617,7 +623,7 @@ def __repr__(self): ) -def non_blocking_websocket(client, output_queue, provider_message_queue): +def non_blocking_websocket(client, output_queue, provider_message_queue) -> None: """Communicate with the threaded process.""" try: while not client._stop_log_thread_event.is_set(): @@ -639,8 +645,8 @@ def non_blocking_websocket(client, output_queue, provider_message_queue): def send_message( client, message, target: Literal["provider", "broadcast"] = "provider" -): - """Send a message to the WebSocket process.""" +) -> None: + """Send a message to the WebSocketConnection process.""" try: if target == "provider": if client._process and client._process.stdin: @@ -661,7 +667,7 @@ def send_message( def read_message_queue( client, message_queue, target: Literal["provider", "broadcast"] = "provider" ): - """Read messages from the queue and send them to the WebSocket process.""" + """Read messages from the queue and send them to the WebSocketConnection process.""" while not message_queue.empty(): try: if target == "provider": @@ -681,7 +687,7 @@ def read_message_queue( break -def non_blocking_broadcast(client, output_queue, broadcast_message_queue): +def non_blocking_broadcast(client, output_queue, broadcast_message_queue) -> None: """Continuously read the output from the broadcast process and log it to the main thread.""" try: while not client._stop_broadcasting_event.is_set(): diff --git a/openbb_platform/extensions/websockets/openbb_websockets/models.py b/openbb_platform/extensions/websockets/openbb_websockets/models.py index e50833f09613..19d59769d5cc 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/models.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/models.py @@ -123,3 +123,47 @@ def _validate_client(cls, v): if not isinstance(v, WebSocketClient): raise ValueError("Client must be an instance of WebSocketClient.") return v + + +class WebSocketConnectionStatus(Data): + """Data model for WebSocketConnection status information.""" + + name: str = Field( + description="Name assigned to the client connection.", + ) + auth_required: bool = Field( + description="True when 'auth_token' is supplied at initialization." + " When True, interactions with the client from the Python or API" + + " endpoints requires it to be supplied as a query parameter.", + ) + subscribed_symbols: str = Field( + description="Symbols subscribed to by the client connection.", + ) + is_running: bool = Field( + description="Whether the client connection is running.", + ) + provider_pid: Optional[int] = Field( + default=None, + description="Process ID of the provider connection.", + ) + is_broadcasting: bool = Field( + description="Whether the client connection is broadcasting.", + ) + broadcast_address: Optional[str] = Field( + default=None, + description="URI to the broadcast server.", + ) + broadcast_pid: Optional[int] = Field( + default=None, + description="Process ID of the broadcast server.", + ) + results_file: Optional[str] = Field( + description="Absolute path to the file for continuous writing.", + ) + table_name: Optional[str] = Field( + default=None, + description="Name of the SQL table to write the results to.", + ) + save_results: bool = Field( + description="Whether to save the results after the session ends.", + ) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 18bbbb85bc15..29d5c0446e8b 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -25,6 +25,7 @@ connected_clients, get_status, ) +from openbb_websockets.models import WebSocketConnectionStatus router = Router("", description="WebSockets Router") sys.stdout = StdOutSink() @@ -38,7 +39,7 @@ async def create_connection( provider_choices: ProviderChoices, standard_params: StandardParams, extra_params: ExtraParams, -) -> OBBject: +) -> OBBject[WebSocketConnectionStatus]: """Create a new provider websocket connection.""" name = extra_params.name if name in connected_clients: @@ -67,7 +68,7 @@ async def create_connection( connected_clients[client_name] = client results = await get_status(client_name) - obbject.results = results + obbject.results = WebSocketConnectionStatus(**results) return obbject @@ -205,7 +206,9 @@ async def unsubscribe( @router.command( methods=["GET"], ) -async def get_client_status(name: str = "all") -> OBBject[list[dict]]: +async def get_client_status( + name: str = "all", +) -> OBBject[list[WebSocketConnectionStatus]]: """Get the status of a client, or all client connections. Parameters @@ -226,7 +229,7 @@ async def get_client_status(name: str = "all") -> OBBject[list[dict]]: ] else: connections = [await get_status(name)] - return OBBject(results=connections) + return OBBject(results=[WebSocketConnectionStatus(**d) for d in connections]) @router.command( diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 5972f42cea6b..4753f560062e 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -193,8 +193,6 @@ def _validate_date(cls, v): def _validate_model(cls, values): """Validate the model.""" _ = values.pop("s", None) - if values.get("z") and values["z"] == 0 or not values.get("z"): - _ = values.pop("z", None) return values @@ -839,13 +837,15 @@ def extract_data( try: client.connect() - - except Exception as e: # pylint: disable=broad-except + except Exception as e: client.disconnect() raise OpenBBError(e) from e time.sleep(1) + if client._exception: + raise client._exception from client._exception + if client.is_running: return client diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index 993b86eb6e62..a8b8bbce56b2 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -195,7 +195,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim sys.exit(1) except Exception as e: - msg = f"PROVIDER ERROR: Unexpected error -> {e}" + msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) sys.exit(1) From f1ac74bd6631f9005debdf19f2868f651144252a Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sat, 9 Nov 2024 17:40:02 -0800 Subject: [PATCH 11/40] handle ValidationError --- .../websockets/openbb_websockets/client.py | 26 +++++++++++- .../websockets/openbb_websockets/helpers.py | 9 ++++ .../openbb_websockets/websockets_router.py | 6 ++- .../models/websocket_connection.py | 2 +- .../openbb_polygon/utils/websocket_client.py | 41 +++++++++++++++---- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index ff909bbb0319..1c43612da300 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -185,14 +185,35 @@ async def _setup_database(self) -> None: def _log_provider_output(self, output_queue) -> None: """Log output from the provider server queue.""" # pylint: disable=import-outside-toplevel - import queue # noqa + import json # noqa + import queue import sys from openbb_websockets.helpers import clean_message + from pydantic import ValidationError while not self._stop_log_thread_event.is_set(): try: output = output_queue.get(timeout=1) if output: + if "ValidationError" in output: + self._psutil_process.kill() + self._process.wait() + self._thread.join() + title, errors = output.split(" -> ")[-1].split(": ") + line_errors = json.loads(errors.strip()) + err = ValidationError.from_exception_data( + title=title.strip(), line_errors=line_errors + ) + self._exception = err + msg = ( + "PROVIDER ERROR: Disconnecting because a ValidatonError was raised" + + " by the provider while processing data." + + f"\n\n{str(err)}\n" + ) + sys.stdout.write(msg + "\n") + sys.stdout.flush() + break + if ( "server rejected" in output.lower() or "PROVIDER ERROR" in output @@ -201,6 +222,9 @@ def _log_provider_output(self, output_queue) -> None: err = ChildProcessError(output) self._exception = err self.logger.error(output) + self._psutil_process.kill() + self._process.wait() + self._thread.join() break output = clean_message(output) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index 675e43e2cc61..21539d192087 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -7,6 +7,7 @@ from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.provider.utils.errors import UnauthorizedError +from pydantic import ValidationError AUTH_TOKEN_FILTER = re.compile( r"(auth_token=)([^&]*)", @@ -34,9 +35,17 @@ def get_logger(name, level=logging.INFO): handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(level) + return logger +def handle_validation_error(logger: logging.Logger, error: ValidationError): + """Log and raise a Pydantic ValidationError from a provider connection.""" + err = f"{error.__class__.__name__} -> {error.title}: {str(error.json())}" + logger.error(err) + raise error from error + + async def get_status(name: str) -> dict: """Get the status of a client.""" if name not in connected_clients: diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 29d5c0446e8b..4e0b43f987db 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -58,7 +58,11 @@ async def create_connection( await asyncio.sleep(1) if not client.is_running: - client._atexit() + if client._exception: + exc = getattr(client, "_exception", None) + delattr(client, "_exception") + client._atexit() + raise OpenBBError(exc) raise OpenBBError("Client failed to connect.") if hasattr(extra_params, "start_broadcast") and extra_params.start_broadcast: diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 4753f560062e..8c4ac14a3e65 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -25,7 +25,7 @@ WebSocketData, WebSocketQueryParams, ) -from pydantic import ConfigDict, Field, field_validator, model_validator +from pydantic import Field, field_validator, model_validator URL_MAP = { "stock": "wss://socket.polygon.io/stocks", diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index a8b8bbce56b2..ba8feeee0e15 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -16,9 +16,11 @@ MessageQueue, get_logger, handle_termination_signal, + handle_validation_error, parse_kwargs, write_to_db, ) +from pydantic import ValidationError logger = get_logger("openbb.websocket.polygon") queue = MessageQueue() @@ -36,19 +38,38 @@ async def handle_symbol(symbol): new_symbols: list = [] feed = FEED_MAP.get(ASSET_TYPE, {}).get(FEED) for s in symbols: + if "." in s: + _check = s.split(".")[0] + if _check not in list(FEED_MAP.get(ASSET_TYPE, {}).values()): + logger.error( + "PROVIDER INFO: Invalid feed, %s, for asset type, %s", + _check, + ASSET_TYPE, + ) + continue + if s == "*": new_symbols.append(f"{feed}.*") continue ticker = s.upper() - if ASSET_TYPE == "crypto" and "-" not in ticker and ticker != "*": + if ticker and "." not in ticker and not ticker.startswith(feed): + ticker = f"{feed}.{ticker}" + elif ( + ASSET_TYPE == "crypto" + and "." not in ticker + and "-" not in ticker + and ticker != "*" + ): ticker = ticker[:3] + "-" + ticker[3:] - elif ASSET_TYPE == "fx" and "/" not in ticker and ticker != "*": + elif ( + ASSET_TYPE == "fx" + and "." not in ticker + and "/" not in ticker + and ticker != "*" + ): ticker = ticker[:3] + "/" + ticker[3:] elif ASSET_TYPE == "fx" and "-" in ticker: ticker = ticker.replace("-", "/") - - if ticker and "." not in ticker and not ticker.startswith(feed): - ticker = f"{feed}.{ticker}" new_symbols.append(ticker) return ",".join(new_symbols) @@ -117,10 +138,12 @@ async def process_message(message, results_path, table_name, limit): result = PolygonWebSocketData(**msg).model_dump_json( exclude_none=True, exclude_unset=True ) - except Exception as e: - err = f"PROVIDER ERROR: Error validating data: {e}" - logger.error(err) - return None + except ValidationError as e: + try: + handle_validation_error(logger, e) + except ValidationError: + raise e from e + if result: await write_to_db(result, results_path, table_name, limit) From 402a71f3e6598e87952aa5c99f1a1dfe71c86800 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sat, 9 Nov 2024 17:48:50 -0800 Subject: [PATCH 12/40] use sys.stdout.write instead of logger.error for unexpected error --- .../websockets/openbb_websockets/client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 1c43612da300..b045aec18966 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -217,14 +217,15 @@ def _log_provider_output(self, output_queue) -> None: if ( "server rejected" in output.lower() or "PROVIDER ERROR" in output - or "Unexpected error" in output + or "unexpected error" in output.lower() ): - err = ChildProcessError(output) - self._exception = err - self.logger.error(output) self._psutil_process.kill() self._process.wait() self._thread.join() + err = ChildProcessError(output) + self._exception = err + sys.stdout.write(msg + "\n") + sys.stdout.flush() break output = clean_message(output) @@ -265,9 +266,9 @@ def _log_broadcast_output(self, output_queue) -> None: output = None if output: - if "ERROR:" in output: + if output.startswith("ERROR:"): output = output.replace("ERROR:", "BROADCAST ERROR:") + "\n" - if "INFO:" in output: + elif output.startswith("INFO:"): output = output.replace("INFO:", "BROADCAST INFO:") + "\n" output = output[0] if isinstance(output, tuple) else output output = clean_message(output) From d748e815aaf46409bc814ce5286a4744a759c99e Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sat, 9 Nov 2024 22:43:00 -0800 Subject: [PATCH 13/40] add polygon indices and send message to broadcast server from main client --- .../websockets/openbb_websockets/broadcast.py | 47 +++++-- .../websockets/openbb_websockets/client.py | 7 +- .../models/websocket_connection.py | 129 +++++++++++++++++- .../openbb_polygon/utils/websocket_client.py | 40 +++--- 4 files changed, 187 insertions(+), 36 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py index 9235a6b4bdf1..29bc7b739b31 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py @@ -25,6 +25,31 @@ app = FastAPI() +async def read_stdin(broadcast_server): + """Read from stdin.""" + while True: + line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) + sys.stdin.flush() + + if not line: + break + + try: + command = ( + json.loads(line.strip()) + if line.strip().startswith("{") or line.strip().startswith("[") + else line.strip() + ) + msg = ( + "BROADCAST INFO: Message received from parent process and relayed to active listeners ->" + + f" {json.dumps(command)}" + ) + await broadcast_server.broadcast(json.dumps(command)) + broadcast_server.logger.info(msg) + except json.JSONDecodeError: + broadcast_server.logger.error("Invalid JSON received from stdin") + + @app.websocket("/") async def websocket_endpoint( # noqa: PLR0915 websocket: WebSocket, auth_token: Optional[str] = None @@ -61,29 +86,35 @@ async def websocket_endpoint( # noqa: PLR0915 connected_clients.add(broadcast_server) stream_task = asyncio.create_task(broadcast_server.stream_results()) + stdin_task = asyncio.create_task(read_stdin(broadcast_server)) try: await websocket.receive_text() except WebSocketDisconnect: pass except Exception as e: - broadcast_server.logger.error(f"Unexpected error: {e}") + msg = f"Unexpected error: {e.__class__.__name__} -> {e}" + broadcast_server.logger.error(msg) pass finally: if broadcast_server in connected_clients: connected_clients.remove(broadcast_server) stream_task.cancel() + stdin_task.cancel() try: await stream_task + await stdin_task except asyncio.CancelledError: broadcast_server.logger.info("Stream task cancelled") except Exception as e: - broadcast_server.logger.error(f"Error while cancelling stream task: {e}") + msg = f"Unexpected error while cancelling stream task: {e.__class__.__name__} -> {e}" + broadcast_server.logger.error(msg) if websocket.client_state != WebSocketState.DISCONNECTED: try: await websocket.close() except RuntimeError as e: - broadcast_server.logger.error(f"Error while closing websocket: {e}") + msg = f"Unexpected error while closing websocket: {e.__class__.__name__} -> {e}" + broadcast_server.logger.error(msg) class BroadcastServer: @@ -147,7 +178,7 @@ async def stream_results(self): # noqa: PLR0915 await self.broadcast(json.dumps(json.loads(message))) last_id = max(row[0] for row in rows) else: - self.logger.error(f"Results file not found: {file_path}") + self.logger.error("Results file not found: %s", str(file_path)) break await asyncio.sleep(self.sleep_time) @@ -167,7 +198,8 @@ async def stream_results(self): # noqa: PLR0915 except WebSocketDisconnect: pass except Exception as e: - self.logger.error(f"Unexpected error: {e}") + msg = f"Unexpected error: {e.__class__.__name__} -> {e}" + self.logger.error(msg) finally: return @@ -220,9 +252,8 @@ def main(): **kwargs, ) except TypeError as e: - broadcast_server.logger.error( - f"Invalid keyword argument passed to unvicorn. -> {e.args[0]}\n" - ) + msg = f"Invalid keyword argument passed to unvicorn. -> {e.args[0]}\n" + broadcast_server.logger.error(msg) except KeyboardInterrupt: broadcast_server.logger.info("Broadcast server terminated.") finally: diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index b045aec18966..743d58d51c72 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -224,7 +224,7 @@ def _log_provider_output(self, output_queue) -> None: self._thread.join() err = ChildProcessError(output) self._exception = err - sys.stdout.write(msg + "\n") + sys.stdout.write(output + "\n") sys.stdout.flush() break @@ -672,6 +672,11 @@ def send_message( client, message, target: Literal["provider", "broadcast"] = "provider" ) -> None: """Send a message to the WebSocketConnection process.""" + # pylint: disable=import-outside-toplevel + import json + + if isinstance(message, (dict, list)): + message = json.dumps(message) try: if target == "provider": if client._process and client._process.stdin: diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 8c4ac14a3e65..f7d8d06a07bd 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -30,11 +30,13 @@ URL_MAP = { "stock": "wss://socket.polygon.io/stocks", "stock_delayed": "wss://delayed.polygon.io/stocks", + "index": "wss://socket.polygon.io/indices", + "index_delayed": "wss://delayed.polygon.io/indices", "fx": "wss://socket.polygon.io/forex", "crypto": "wss://socket.polygon.io/crypto", } -ASSET_CHOICES = ["stock", "stock_delayed", "fx", "crypto"] +ASSET_CHOICES = ["stock", "stock_delayed", "fx", "crypto", "index", "index_delayed"] FEED_MAP = { "crypto": { @@ -65,6 +67,16 @@ "quote": "Q", "fmv": "FMV", }, + "index": { + "aggs_min": "AM", + "aggs_sec": "AS", + "value": "V", + }, + "index_delayed": { + "aggs_min": "AM", + "aggs_sec": "AS", + "value": "V", + }, } @@ -101,7 +113,9 @@ class PolygonWebSocketQueryParams(WebSocketQueryParams): symbol: str = Field( description=QUERY_DESCRIPTIONS.get("symbol", ""), ) - asset_type: Literal["stock", "stock_delayed", "fx", "crypto"] = Field( + asset_type: Literal[ + "stock", "stock_delayed", "fx", "crypto", "index", "index_delayed" + ] = Field( default="crypto", description="The asset type associated with the symbol(s)." + " Choose from: stock, stock_delayed, fx, crypto.", @@ -150,7 +164,8 @@ class PolygonCryptoAggsWebSocketData(WebSocketData): description="The type of data.", ) date: datetime = Field( - description="The start of the aggregate window.", + description=DATA_DESCRIPTIONS.get("date", "") + + "The end of the aggregate window.", ) symbol: str = Field( description=DATA_DESCRIPTIONS.get("symbol", ""), @@ -464,7 +479,8 @@ class PolygonStockAggsWebSocketData(WebSocketData): description="The type of data.", ) date: datetime = Field( - description="The start of the aggregate window.", + description=DATA_DESCRIPTIONS.get("date", "") + + "The end of the aggregate window.", ) symbol: str = Field( description=DATA_DESCRIPTIONS.get("symbol", ""), @@ -547,7 +563,8 @@ class PolygonStockTradeWebSocketData(WebSocketData): description="The type of data.", ) date: datetime = Field( - description="The start of the aggregate window.", + description=DATA_DESCRIPTIONS.get("date", "") + + "The end of the aggregate window.", ) symbol: str = Field( description=DATA_DESCRIPTIONS.get("symbol", ""), @@ -641,7 +658,8 @@ class PolygonStockQuoteWebSocketData(WebSocketData): description="The type of data.", ) date: datetime = Field( - description="The start of the aggregate window.", + description=DATA_DESCRIPTIONS.get("date", "") + + "The end of the aggregate window.", ) symbol: str = Field( description=DATA_DESCRIPTIONS.get("symbol", ""), @@ -727,6 +745,94 @@ def _validate_model(cls, values): return values +class PolygonIndexAggsWebSocketData(WebSocketData): + """Polygon Index Aggregates WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "e", + "day_open": "op", + "open": "o", + "high": "h", + "low": "l", + "close": "c", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", "") + + "The end of the aggregate window.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + day_open: float = Field( + description="Today's official opening level.", + ) + open: float = Field( + description=DATA_DESCRIPTIONS.get("open", "") + + " For the current aggregate window.", + ) + high: float = Field( + description=DATA_DESCRIPTIONS.get("high", "") + + " For the current aggregate window.", + ) + low: float = Field( + description=DATA_DESCRIPTIONS.get("low", "") + + " For the current aggregate window.", + ) + close: float = Field( + description=DATA_DESCRIPTIONS.get("close", "") + + " For the current aggregate window.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("s", None) + return values + + +class PolygonIndexValueWebSocketData(WebSocketData): + """Polygon Index Value WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "T", + "date": "t", + "value": "val", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + value: float = Field( + description="The value of the index.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + class PolygonFairMarketValueData(WebSocketData): """Polygon Fair Market Value WebSocket Data.""" @@ -767,6 +873,8 @@ class PolygonFairMarketValueData(WebSocketData): "AS": PolygonStockAggsWebSocketData, "T": PolygonStockTradeWebSocketData, "Q": PolygonStockQuoteWebSocketData, + "A": PolygonIndexAggsWebSocketData, + "V": PolygonIndexValueWebSocketData, } @@ -775,7 +883,14 @@ class PolygonWebSocketData(Data): def __new__(cls, **data): """Create new instance of appropriate model type.""" - model = MODEL_MAP.get(data.get("ev")) or MODEL_MAP.get(data.get("type")) + index_symbol = data.get("sym", "").startswith("I:") or data.get( + "symbol", "" + ).startswith("I:") + model = ( + MODEL_MAP["A"] + if index_symbol + else MODEL_MAP.get(data.get("ev")) or MODEL_MAP.get(data.get("type")) + ) if not model: return super().__new__(cls) diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index ba8feeee0e15..39cbf0dd6a7f 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -38,6 +38,11 @@ async def handle_symbol(symbol): new_symbols: list = [] feed = FEED_MAP.get(ASSET_TYPE, {}).get(FEED) for s in symbols: + + if s == "*": + new_symbols.append(f"{feed}.*") + continue + if "." in s: _check = s.split(".")[0] if _check not in list(FEED_MAP.get(ASSET_TYPE, {}).values()): @@ -48,28 +53,21 @@ async def handle_symbol(symbol): ) continue - if s == "*": - new_symbols.append(f"{feed}.*") - continue ticker = s.upper() - if ticker and "." not in ticker and not ticker.startswith(feed): + + if ticker and "." not in ticker: ticker = f"{feed}.{ticker}" - elif ( - ASSET_TYPE == "crypto" - and "." not in ticker - and "-" not in ticker - and ticker != "*" - ): + + if ASSET_TYPE == "crypto" and "-" not in ticker and ticker != "*": ticker = ticker[:3] + "-" + ticker[3:] - elif ( - ASSET_TYPE == "fx" - and "." not in ticker - and "/" not in ticker - and ticker != "*" - ): + elif ASSET_TYPE == "fx" and "/" not in ticker and ticker != "*": ticker = ticker[:3] + "/" + ticker[3:] elif ASSET_TYPE == "fx" and "-" in ticker: ticker = ticker.replace("-", "/") + elif ASSET_TYPE == "index" and ":" not in ticker and ticker != "*": + _feed, _ticker = ticker.split(".") if "." in ticker else (feed, ticker) + ticker = f"{_feed}.I:{_ticker}" + new_symbols.append(ticker) return ",".join(new_symbols) @@ -87,12 +85,12 @@ async def login(websocket, api_key): logger.info("PROVIDER INFO: %s", msg.get("message")) continue if msg.get("status") != "auth_success": - err = f"PROVIDER ERROR: {msg.get('status')} -> {msg.get('message')}" + err = f"PROVIDER ERROR: {msg.get('status')} -> {msg.get('message')}" logger.error(err) sys.exit(1) logger.info("PROVIDER INFO: %s", msg.get("message")) except Exception as e: - logger.error("PROVIDER ERROR: %s", e.args[0]) + logger.error("PROVIDER ERROR: %s -> %s", e.__class__.__name__, e.args[0]) sys.exit(1) @@ -103,7 +101,7 @@ async def subscribe(websocket, symbol, event): try: await websocket.send(subscribe_event) except Exception as e: - msg = f"PROVIDER ERROR: {e}" + msg = f"PROVIDER ERROR: {e.__class__.__name__} -> {e}" logger.error(msg) @@ -146,6 +144,8 @@ async def process_message(message, results_path, table_name, limit): if result: await write_to_db(result, results_path, table_name, limit) + else: + logger.info("PROVIDER INFO: %s", msg) async def connect_and_stream(url, symbol, api_key, results_path, table_name, limit): @@ -255,7 +255,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim logger.error("PROVIDER ERROR: WebSocket connection closed") except Exception as e: # pylint: disable=broad-except - msg = f"PROVIDER ERROR: {e.args[0]}" + msg = f"PROVIDER ERROR: {e.__class__.__name__} -> {e}" logger.error(msg) finally: From 82b706a41e324d02b4554d700ca723305f57b503 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sun, 10 Nov 2024 12:21:31 -0800 Subject: [PATCH 14/40] handle UnauthorizedError and map polygon options feeds --- .../websockets/openbb_websockets/client.py | 12 + .../openbb_websockets/websockets_router.py | 4 +- .../models/websocket_connection.py | 254 +++++++++++++++++- .../polygon/openbb_polygon/utils/constants.py | 60 +++++ .../openbb_polygon/utils/websocket_client.py | 48 +++- 5 files changed, 360 insertions(+), 18 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 743d58d51c72..714b175125ad 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -188,6 +188,7 @@ def _log_provider_output(self, output_queue) -> None: import json # noqa import queue import sys + from openbb_core.provider.utils.errors import UnauthorizedError from openbb_websockets.helpers import clean_message from pydantic import ValidationError @@ -195,6 +196,17 @@ def _log_provider_output(self, output_queue) -> None: try: output = output_queue.get(timeout=1) if output: + # Handle raised exceptions from the provider connection thread. + if "UnauthorizedError" in output: + self._psutil_process.kill() + self._process.wait() + self._thread.join() + err = UnauthorizedError(output) + self._exception = err + sys.stdout.write(output + "\n") + sys.stdout.flush() + break + if "ValidationError" in output: self._psutil_process.kill() self._process.wait() diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 4e0b43f987db..91fd66967cda 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -17,7 +17,7 @@ ) from openbb_core.app.query import Query from openbb_core.app.router import Router -from openbb_core.provider.utils.errors import EmptyDataError +from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError from openbb_websockets.helpers import ( StdOutSink, @@ -62,6 +62,8 @@ async def create_connection( exc = getattr(client, "_exception", None) delattr(client, "_exception") client._atexit() + if isinstance(exc, UnauthorizedError): + raise exc raise OpenBBError(exc) raise OpenBBError("Client failed to connect.") diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index f7d8d06a07bd..5aa96ed83623 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -1,5 +1,7 @@ """Polygon WebSocket Connection Model.""" +# pylint: disable=unused-argument, too-many-lines + from datetime import datetime from typing import Any, Literal, Optional @@ -13,6 +15,8 @@ from openbb_polygon.utils.constants import ( CRYPTO_EXCHANGE_MAP, FX_EXCHANGE_MAP, + OPTIONS_EXCHANGE_MAP, + OPTIONS_TRADE_CONDITIONS, STOCK_EXCHANGE_MAP, STOCK_QUOTE_CONDITIONS, STOCK_QUOTE_INDICATORS, @@ -30,13 +34,24 @@ URL_MAP = { "stock": "wss://socket.polygon.io/stocks", "stock_delayed": "wss://delayed.polygon.io/stocks", - "index": "wss://socket.polygon.io/indices", - "index_delayed": "wss://delayed.polygon.io/indices", + "options": "wss://socket.polygon.io/options", + "options_delayed": "wss://delayed.polygon.io/options", "fx": "wss://socket.polygon.io/forex", "crypto": "wss://socket.polygon.io/crypto", + "index": "wss://socket.polygon.io/indices", + "index_delayed": "wss://delayed.polygon.io/indices", } -ASSET_CHOICES = ["stock", "stock_delayed", "fx", "crypto", "index", "index_delayed"] +ASSET_CHOICES = [ + "stock", + "stock_delayed", + "options", + "options_delayed", + "fx", + "crypto", + "index", + "index_delayed", +] FEED_MAP = { "crypto": { @@ -77,6 +92,20 @@ "aggs_sec": "AS", "value": "V", }, + "options": { + "aggs_min": "AM", + "aggs_sec": "A", + "trade": "T", + "quote": "Q", + "fmv": "FMV", + }, + "options_delayed": { + "aggs_min": "AM", + "aggs_sec": "A", + "trade": "T", + "quote": "Q", + "fmv": "FMV", + }, } @@ -111,10 +140,20 @@ class PolygonWebSocketQueryParams(WebSocketQueryParams): } symbol: str = Field( - description=QUERY_DESCRIPTIONS.get("symbol", ""), + description=QUERY_DESCRIPTIONS.get("symbol", "") + + " All feeds, except Options, support the wildcard symbol, '*', to subscribe to all symbols." + + " For Options, the OCC contract symbol is used to subscribe up to 1000 individual contracts" + + " per connection." ) asset_type: Literal[ - "stock", "stock_delayed", "fx", "crypto", "index", "index_delayed" + "stock", + "stock_delayed", + "options", + "options_delayed", + "fx", + "crypto", + "index", + "index_delayed", ] = Field( default="crypto", description="The asset type associated with the symbol(s)." @@ -833,6 +872,146 @@ def _validate_date(cls, v): return validate_date(cls, v) +class PolygonOptionsTradeWebSocketData(WebSocketData): + """Polygon Options Trade WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "t", + "exchange": "x", + "price": "p", + "size": "s", + "conditions": "c", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + price: float = Field( + description="The price of the trade.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + size: float = Field( + description="The size of the trade.", + ) + exchange: str = Field( + description="The exchange where the trade originated.", + ) + conditions: Optional[str] = Field( + default=None, + description="The conditions of the trade.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return OPTIONS_EXCHANGE_MAP.get(v, str(v)) + + @field_validator("conditions", mode="before", check_fields=False) + @classmethod + def _validate_conditions(cls, v): + """Validate the conditions.""" + if v is None or not v: + return None + new_conditions: list = [] + if isinstance(v, list): + for c in v: + new_conditions.append(OPTIONS_TRADE_CONDITIONS.get(c, str(c))) + elif isinstance(v, int): + new_conditions.append(OPTIONS_TRADE_CONDITIONS.get(v, str(v))) + + if not new_conditions: + return None + return "; ".join(new_conditions) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("q", None) + return values + + +class PolygonOptionsQuoteWebSocketData(WebSocketData): + """Polygon Options Quote WebSocket data model.""" + + __alias_dict__ = { + "type": "ev", + "symbol": "sym", + "date": "t", + "bid_exchange": "bx", + "bid_size": "bs", + "bid": "bp", + "ask": "ap", + "ask_size": "as", + "ask_exchange": "ax", + } + + type: str = Field( + description="The type of data.", + ) + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", "") + + "The end of the aggregate window.", + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + bid_exchange: str = Field( + description="The exchange where the bid originated.", + ) + bid_size: float = Field( + description="The size of the bid.", + ) + bid: float = Field( + description="The bid price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask: float = Field( + description="The ask price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_size: float = Field( + description="The size of the ask.", + ) + ask_exchange: str = Field( + description="The exchange where the ask originated.", + ) + + @field_validator("date", mode="before", check_fields=False) + @classmethod + def _validate_date(cls, v): + """Validate the date.""" + return validate_date(cls, v) + + @field_validator("bid_exchange", "ask_exchange", mode="before", check_fields=False) + @classmethod + def _validate_exchange(cls, v): + """Validate the exchange.""" + return OPTIONS_EXCHANGE_MAP.get(v, str(v)) + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("q", None) + return values + + class PolygonFairMarketValueData(WebSocketData): """Polygon Fair Market Value WebSocket Data.""" @@ -877,20 +1056,73 @@ class PolygonFairMarketValueData(WebSocketData): "V": PolygonIndexValueWebSocketData, } +OPTIONS_MODEL_MAP = { + "AM": PolygonStockAggsWebSocketData, + "A": PolygonStockAggsWebSocketData, + "T": PolygonOptionsTradeWebSocketData, + "Q": PolygonOptionsQuoteWebSocketData, + "FMV": PolygonFairMarketValueData, +} + class PolygonWebSocketData(Data): - """Polygon WebSocket data model.""" + """Polygon WebSocket data model. This model is used to identify the appropriate model for the data. + The model is determined based on the type of data received from the WebSocket. + Some asset feeds share common data structures with other asset feeds - for example, FX and Crypto aggregates. + + Stock + ----- + - Aggs: AS, AM - PolygonStockAggsWebSocketData + - Trade: T - PolygonStockTradeWebSocketData + - Quote: Q - PolygonStockQuoteWebSocketData + - Fair Market Value: FMV - PolygonFairMarketValueData + + Options + ------- + - Aggs: A, AM - PolygonStockAggsWebSocketData + - Trade: T - PolygonOptionsTradeWebSocketData + - Quote: Q - PolygonOptionsQuoteWebSocketData + - Fair Market Value: FMV - PolygonFairMarketValueData + + Index + ----- + - Aggs: A, AM - PolygonIndexAggsWebSocketData + - Value: V - PolygonIndexValueWebSocketData + + Crypto + ------ + - Aggs: XAS, XA - PolygonCryptoAggsWebSocketData + - Trade: XT - PolygonCryptoTradeWebSocketData + - Quote: XQ - PolygonCryptoQuoteWebSocketData + - L2: XL2 - PolygonCryptoL2WebSocketData + - Fair Market Value: FMV - PolygonFairMarketValueData + + FX + -- + - Aggs: CAS, CA - PolygonCryptoAggsWebSocketData + - Quote: C - PolygonFXQuoteWebSocketData + - Fair Market Value: FMV - PolygonFairMarketValueData + """ def __new__(cls, **data): """Create new instance of appropriate model type.""" index_symbol = data.get("sym", "").startswith("I:") or data.get( "symbol", "" ).startswith("I:") - model = ( - MODEL_MAP["A"] - if index_symbol - else MODEL_MAP.get(data.get("ev")) or MODEL_MAP.get(data.get("type")) - ) + options_symbol = data.get("sym", "").startswith("O:") or data.get( + "symbol", "" + ).startswith("O:") + + if options_symbol: + model = OPTIONS_MODEL_MAP.get(data.get("ev")) or OPTIONS_MODEL_MAP.get( + data.get("type") + ) + else: + model = ( + MODEL_MAP["A"] + if index_symbol + else MODEL_MAP.get(data.get("ev")) or MODEL_MAP.get(data.get("type")) + ) if not model: return super().__new__(cls) diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py index 4922e6eaf5fc..9b827c091161 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py @@ -184,3 +184,63 @@ 907: "CTA_CORRECTED_PRICE_INDICATION", 908: "CTA_CANCELLED_MARKET_IMBALANCE_PRICE_TRADING_RANGE_INDICATION", } + +OPTIONS_EXCHANGE_MAP = { + 300: "XNYS", + 301: "XBOX", + 302: "XCBO", + 303: "MIHI", + 304: "XCBO", + 307: "GEMX", + 308: "XISX", + 309: "XISX", + 310: "XISX", + 312: "MIHI", + 313: "XNYS", + 314: "OPRA", + 315: "MIHI", + 316: "XNAS", + 318: "MIHI", + 319: "XNAS", + 320: "MEMX", + 322: "XCBO", + 323: "XNAS", + 325: "XCBO", +} + + +OPTIONS_TRADE_CONDITIONS = { + 201: "Canceled", + 202: "Late and Out Of Sequence", + 203: "Last and Canceled", + 204: "Late", + 205: "Opening Trade and Canceled", + 206: "Opening Trade, Late, and Out Of Sequence", + 207: "Only Trade and Canceled", + 208: "Opening Trade and Late", + 209: "Automatic Execution", + 210: "Reopening Trade", + 219: "Intermarket Sweep Order", + 227: "Single Leg Auction Non ISO", + 228: "Single Leg Auction ISO", + 229: "Single Leg Cross Non ISO", + 230: "Single Leg Cross ISO", + 231: "Single Leg Floor Trade", + 232: "Multi Leg auto-electronic trade", + 233: "Multi Leg Auction", + 234: "Multi Leg Cross", + 235: "Multi Leg floor trade", + 236: "Multi Leg auto-electronic trade against single leg(s)", + 237: "Stock Options Auction", + 238: "Multi Leg Auction against single leg(s)", + 239: "Multi Leg floor trade against single leg(s)", + 240: "Stock Options auto-electronic trade", + 241: "Stock Options Cross", + 242: "Stock Options floor trade", + 243: "Stock Options auto-electronic trade against single leg(s)", + 244: "Stock Options Auction against single leg(s)", + 245: "Stock Options floor trade against single leg(s)", + 246: "Multi Leg Floor Trade of Proprietary Products", + 247: "Multilateral Compression Trade of Proprietary Products", + 248: "Extended Hours Trade", +} diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index 39cbf0dd6a7f..f7248f3cd5ed 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -8,6 +8,7 @@ import websockets import websockets.exceptions +from openbb_core.provider.utils.errors import UnauthorizedError from openbb_polygon.models.websocket_connection import ( FEED_MAP, PolygonWebSocketData, @@ -39,6 +40,12 @@ async def handle_symbol(symbol): feed = FEED_MAP.get(ASSET_TYPE, {}).get(FEED) for s in symbols: + if ASSET_TYPE == "options" and "*" in s: + logger.error( + "PROVIDER INFO: Options symbols do not support wildcards." + ) + continue + if s == "*": new_symbols.append(f"{feed}.*") continue @@ -64,9 +71,16 @@ async def handle_symbol(symbol): ticker = ticker[:3] + "/" + ticker[3:] elif ASSET_TYPE == "fx" and "-" in ticker: ticker = ticker.replace("-", "/") - elif ASSET_TYPE == "index" and ":" not in ticker and ticker != "*": + elif ( + ASSET_TYPE in ["index", "index_delayed"] + and ":" not in ticker + and ticker != "*" + ): _feed, _ticker = ticker.split(".") if "." in ticker else (feed, ticker) ticker = f"{_feed}.I:{_ticker}" + elif ASSET_TYPE in ["options", "options_delayed"] and ":" not in ticker: + _feed, _ticker = ticker.split(".") if "." in ticker else (feed, ticker) + ticker = f"{_feed}.O:{_ticker}" new_symbols.append(ticker) @@ -85,9 +99,12 @@ async def login(websocket, api_key): logger.info("PROVIDER INFO: %s", msg.get("message")) continue if msg.get("status") != "auth_success": - err = f"PROVIDER ERROR: {msg.get('status')} -> {msg.get('message')}" + err = ( + f"UnauthorizedError -> {msg.get('status')} -> {msg.get('message')}" + ) logger.error(err) sys.exit(1) + raise UnauthorizedError(f"{msg.get('status')} -> {msg.get('message')}") logger.info("PROVIDER INFO: %s", msg.get("message")) except Exception as e: logger.error("PROVIDER ERROR: %s -> %s", e.__class__.__name__, e.args[0]) @@ -125,6 +142,12 @@ async def process_message(message, results_path, table_name, limit): """Process the WebSocket message.""" messages = message if isinstance(message, list) else [message] for msg in messages: + if "Your plan doesn't include websocket access" in msg.get("message"): + err = f"UnauthorizedError -> {msg.get('message')}" + logger.error(err) + sys.exit(1) + raise UnauthorizedError(msg.get("message")) + if "status" in msg or "message" in msg: if "status" in msg and msg["status"] == "error": err = msg.get("message") @@ -198,14 +221,27 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim await subscribe(websocket, symbol, event) except websockets.InvalidStatusCode as e: if e.status_code == 404: - msg = f"PROVIDER ERROR: {e}" + msg = f"PROVIDER ERROR: {e.__str__()}" logger.error(msg) sys.exit(1) else: raise + except websockets.InvalidURI as e: + msg = f"PROVIDER ERROR: {e.__str__()}" + logger.error(msg) + sys.exit(1) + + except websockets.ConnectionClosedOK as e: + msg = ( + f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" + ) + logger.info(msg) + sys.exit(0) except websockets.ConnectionClosed as e: - msg = f"PROVIDER INFO: The WebSocket connection was closed -> {str(e)}" + msg = ( + f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" + ) logger.info(msg) # Attempt to reopen the connection logger.info("PROVIDER INFO: Attempting to reconnect after five seconds.") @@ -213,12 +249,12 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim await connect_and_stream(url, symbol, api_key, results_path, table_name, limit) except websockets.WebSocketException as e: - msg = f"PROVIDER ERROR: WebSocketException -> {e}" + msg = f"PROVIDER ERROR: WebSocketException -> {e.__str__()}" logger.error(msg) sys.exit(1) except Exception as e: - msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e.__str__()}" + msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) sys.exit(1) From 2489128b67567e2b096290ed7ce705dca680bcc5 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sun, 10 Nov 2024 12:45:17 -0800 Subject: [PATCH 15/40] clear exceptions atexit --- .../extensions/websockets/openbb_websockets/client.py | 2 ++ .../websockets/openbb_websockets/websockets_router.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 714b175125ad..e5b909185851 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -166,6 +166,8 @@ def _atexit(self) -> None: # pylint: disable=import-outside-toplevel import os + self._exception = None + if self.is_running: self.disconnect() if self.is_broadcasting: diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 91fd66967cda..e75e7d9457f0 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -60,7 +60,6 @@ async def create_connection( if not client.is_running: if client._exception: exc = getattr(client, "_exception", None) - delattr(client, "_exception") client._atexit() if isinstance(exc, UnauthorizedError): raise exc @@ -159,14 +158,19 @@ async def subscribe( """ if not await check_auth(name, auth_token): raise OpenBBError("Error finding client.") + client = connected_clients[name] symbols = client.symbol.split(",") + if symbols and symbol in symbols: raise OpenBBError(f"Client {name} already subscribed to {symbol}.") + client.subscribe(symbol) - # await asyncio.sleep(2) + await asyncio.sleep(1) + if client.is_running: return OBBject(results=f"Added {symbol} to client {name} connection.") + client.logger.error( f"Client {name} failed to subscribe to {symbol} and is not running." ) From 1e82e1e08f2c95ca23e75fb8fdab9dc93dfcb4bd Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sun, 10 Nov 2024 13:08:40 -0800 Subject: [PATCH 16/40] polygon symbol handling edge case --- .../extensions/websockets/openbb_websockets/client.py | 3 ++- .../websockets/openbb_websockets/websockets_router.py | 6 +++--- .../polygon/openbb_polygon/utils/websocket_client.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index e5b909185851..85a0ee8c1c2e 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -705,7 +705,8 @@ def send_message( else: client.logger.error("Broadcast process is not running.") except Exception as e: - client.logger.error(f"Error sending message to WebSocket process: {e}") + msg = f"Error sending message to WebSocket process: {e.__class__.__name__} -> {e.__str__()}" + client.logger.error(msg) def read_message_queue( diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index e75e7d9457f0..bd061c032b71 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -57,9 +57,9 @@ async def create_connection( await asyncio.sleep(1) - if not client.is_running: - if client._exception: - exc = getattr(client, "_exception", None) + if not client.is_running or client._exception: + exc = getattr(client, "_exception", None) + if exc: client._atexit() if isinstance(exc, UnauthorizedError): raise exc diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index f7248f3cd5ed..f0c21e379d2d 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -65,9 +65,9 @@ async def handle_symbol(symbol): if ticker and "." not in ticker: ticker = f"{feed}.{ticker}" - if ASSET_TYPE == "crypto" and "-" not in ticker and ticker != "*": + if ASSET_TYPE == "crypto" and "-" not in ticker and "*" not in ticker: ticker = ticker[:3] + "-" + ticker[3:] - elif ASSET_TYPE == "fx" and "/" not in ticker and ticker != "*": + elif ASSET_TYPE == "fx" and "/" not in ticker and "*" not in ticker: ticker = ticker[:3] + "/" + ticker[3:] elif ASSET_TYPE == "fx" and "-" in ticker: ticker = ticker.replace("-", "/") From 632342072a730377a74814d52025e90eb8b802da Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Sun, 10 Nov 2024 21:46:00 -0800 Subject: [PATCH 17/40] add symbol error handling and move symbol to provider models for custom docstrings. --- .../websockets/openbb_websockets/client.py | 28 +++++- .../websockets/openbb_websockets/models.py | 3 - .../openbb_websockets/websockets_router.py | 6 +- .../models/websocket_connection.py | 85 +++++++++++++++---- .../openbb_polygon/utils/websocket_client.py | 82 ++++++++++-------- 5 files changed, 145 insertions(+), 59 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 85a0ee8c1c2e..234a553e2d12 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -185,7 +185,7 @@ async def _setup_database(self) -> None: return await setup_database(self.results_path, self.table_name) def _log_provider_output(self, output_queue) -> None: - """Log output from the provider server queue.""" + """Log output from the provider logger, handling exceptions, errors, and messages that are not data.""" # pylint: disable=import-outside-toplevel import json # noqa import queue @@ -198,7 +198,7 @@ def _log_provider_output(self, output_queue) -> None: try: output = output_queue.get(timeout=1) if output: - # Handle raised exceptions from the provider connection thread. + # Handle raised exceptions from the provider connection thread, killing the process if required. if "UnauthorizedError" in output: self._psutil_process.kill() self._process.wait() @@ -241,6 +241,11 @@ def _log_provider_output(self, output_queue) -> None: sys.stdout.write(output + "\n") sys.stdout.flush() break + # We don't kill the process on SymbolError, but raise the exception in the main thread instead. + if "SymbolError" in output: + err = ValueError(output) + self._exception = err + continue output = clean_message(output) output = output + "\n" @@ -296,10 +301,11 @@ def connect(self) -> None: # pylint: disable=import-outside-toplevel import json # noqa import os + import psutil import queue import subprocess import threading - import psutil + import time if self.is_running: self.logger.info("Provider connection already running.") @@ -354,6 +360,13 @@ def connect(self) -> None: self._log_thread.daemon = True self._log_thread.start() + time.sleep(0.75) + + if self._exception is not None: + exc = getattr(self, "_exception", None) + self._exception = None + raise exc + if not self.is_running: self.logger.error("The provider server failed to start.") @@ -392,11 +405,18 @@ def disconnect(self) -> None: def subscribe(self, symbol) -> None: """Subscribe to a new symbol or list of symbols.""" # pylint: disable=import-outside-toplevel - import json + import json # noqa + import time + from openbb_core.app.model.abstract.error import OpenBBError ticker = symbol if isinstance(symbol, list) else symbol.split(",") msg = {"event": "subscribe", "symbol": ticker} self.send_message(json.dumps(msg)) + time.sleep(0.1) + if self._exception: + exc = getattr(self, "_exception", None) + self._exception = None + raise OpenBBError(exc) old_symbols = self.symbol.split(",") new_symbols = list(set(old_symbols + ticker)) self._symbol = ",".join(new_symbols) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/models.py b/openbb_platform/extensions/websockets/openbb_websockets/models.py index 19d59769d5cc..48ca4266595e 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/models.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/models.py @@ -18,9 +18,6 @@ class WebSocketQueryParams(QueryParams): """Query parameters for WebSocket connection creation.""" - symbol: str = Field( - description=QUERY_DESCRIPTIONS.get("symbol", ""), - ) name: str = Field( description="Name to assign the client connection.", ) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index bd061c032b71..1b9473436d66 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -165,8 +165,10 @@ async def subscribe( if symbols and symbol in symbols: raise OpenBBError(f"Client {name} already subscribed to {symbol}.") - client.subscribe(symbol) - await asyncio.sleep(1) + try: + client.subscribe(symbol) + except OpenBBError as e: + raise e from e if client.is_running: return OBBject(results=f"Added {symbol} to client {name} connection.") diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 5aa96ed83623..7cc4830b3e47 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -140,10 +140,52 @@ class PolygonWebSocketQueryParams(WebSocketQueryParams): } symbol: str = Field( - description=QUERY_DESCRIPTIONS.get("symbol", "") - + " All feeds, except Options, support the wildcard symbol, '*', to subscribe to all symbols." - + " For Options, the OCC contract symbol is used to subscribe up to 1000 individual contracts" - + " per connection." + description="Polygon symbol to get data for." + + " All feeds, except Options, support the wildcard symbol, '*', for all symbols." + + "\n Options symbols are the OCC contract symbol and support up to 1000 individual contracts" + + " per connection. Crypto and FX symbols should be entered as a pair, i.e., 'BTCUSD', 'JPYUSD'." + + "\n Multiple feeds can be subscribed to - i.e, aggs and quote - by formatting the symbol" + + " with prefixes described below. No prefix required for symbols within the 'feed' parameter." + + " All subscribed symbols must be from the same 'asset_type' for a single connection." + + """ \n + Stock + ----- + - aggs_min: AM. + - aggs_sec: AS. + - trade: T. + - quote: Q. + - fmv: FMV. + + Options + ------- + - aggs_min: AM.O: + - aggs_sec: A.O: + - trade: T.O: + - quote: Q.O: + - fmv: FMV.O: + + Index + ----- + - aggs_min: AM.I: + - aggs_sec: A.I: + - value: V.I: + + Crypto + ------ + - aggs_min: XA. + - aggs_sec: XAS. + - trade: XT. + - quote: XQ. + - l2: XL2. + - fmv: FMV. + + FX + -- + - aggs_min: CA. + - aggs_sec: CAS. + - quote: C. + - fmv: FMV. + \n\n""" ) asset_type: Literal[ "stock", @@ -159,10 +201,12 @@ class PolygonWebSocketQueryParams(WebSocketQueryParams): description="The asset type associated with the symbol(s)." + " Choose from: stock, stock_delayed, fx, crypto.", ) - feed: Literal["aggs_min", "aggs_sec", "trade", "quote", "l2"] = Field( - default="aggs_sec", - description="The feed type to subscribe to. Choose from: aggs_min, aggs_sec, trade, quote, l2." - + "l2 is only available for crypto.", + feed: Literal["aggs_min", "aggs_sec", "trade", "quote", "l2", "fmv", "value"] = ( + Field( + default="aggs_sec", + description="The feed type to subscribe to. Choose from: aggs_min, aggs_sec, trade, quote, l2, fmv, value" + + "l2 is only available for crypto. value is only available for index.", + ) ) @model_validator(mode="before") @@ -171,15 +215,26 @@ def _validate_feed(cls, values): """Validate the feed.""" feed = values.get("feed") asset_type = values.get("asset_type") - if asset_type == "fx" and feed in ["trade", "l2"]: - raise ValueError("FX does not support the trade or l2 feeds.") - if asset_type in ["stock", "stock_delayed"] and feed == "l2": - raise ValueError("Stock does not support the l2 feed.") - if asset_type == "index" and feed in ["trade", "quote", "l2", "fmv"]: + if asset_type == "fx" and feed in ["trade", "l2", "value"]: + raise ValueError(f"FX does not support the {feed} feed.") + if asset_type in [ + "stock", + "stock_delayed", + "options", + "options_delayed", + ] and feed in ["l2", "value"]: raise ValueError( - "Index does not support the trade, quote, l2, or fmv feeds." + f"Asset type, {asset_type}, does not support the {feed} feed." ) - + if asset_type in ["index", "index_delayed"] and feed in [ + "trade", + "quote", + "l2", + "fmv", + ]: + raise ValueError(f"Index does not support the {feed} feed.") + if asset_type == "crypto" and feed == "value": + raise ValueError(f"Crypto does not support the {feed} feed.") return values diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index f0c21e379d2d..74b2e428c39e 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -8,7 +8,6 @@ import websockets import websockets.exceptions -from openbb_core.provider.utils.errors import UnauthorizedError from openbb_polygon.models.websocket_connection import ( FEED_MAP, PolygonWebSocketData, @@ -40,10 +39,11 @@ async def handle_symbol(symbol): feed = FEED_MAP.get(ASSET_TYPE, {}).get(FEED) for s in symbols: - if ASSET_TYPE == "options" and "*" in s: - logger.error( - "PROVIDER INFO: Options symbols do not support wildcards." + if ASSET_TYPE in ["options", "options_delayed"] and "*" in s: + symbol_error = ( + f"SymbolError -> {symbol}: Options symbols do not support wildcards." ) + logger.error(symbol_error) continue if s == "*": @@ -53,12 +53,9 @@ async def handle_symbol(symbol): if "." in s: _check = s.split(".")[0] if _check not in list(FEED_MAP.get(ASSET_TYPE, {}).values()): - logger.error( - "PROVIDER INFO: Invalid feed, %s, for asset type, %s", - _check, - ASSET_TYPE, + raise ValueError( + f"SymbolError -> Invalid feed, {_check}, for asset type, {ASSET_TYPE}" ) - continue ticker = s.upper() @@ -66,7 +63,7 @@ async def handle_symbol(symbol): ticker = f"{feed}.{ticker}" if ASSET_TYPE == "crypto" and "-" not in ticker and "*" not in ticker: - ticker = ticker[:3] + "-" + ticker[3:] + ticker = ticker[:-3] + "-" + ticker[-3:] elif ASSET_TYPE == "fx" and "/" not in ticker and "*" not in ticker: ticker = ticker[:3] + "/" + ticker[3:] elif ASSET_TYPE == "fx" and "-" in ticker: @@ -74,7 +71,7 @@ async def handle_symbol(symbol): elif ( ASSET_TYPE in ["index", "index_delayed"] and ":" not in ticker - and ticker != "*" + and "*" not in ticker ): _feed, _ticker = ticker.split(".") if "." in ticker else (feed, ticker) ticker = f"{_feed}.I:{_ticker}" @@ -98,13 +95,18 @@ async def login(websocket, api_key): if msg.get("status") == "connected": logger.info("PROVIDER INFO: %s", msg.get("message")) continue + if "Your plan doesn't include websocket access" in msg.get("message"): + err = f"UnauthorizedError -> {msg.get('message')}" + logger.error(err) + sys.exit(1) + break if msg.get("status") != "auth_success": err = ( f"UnauthorizedError -> {msg.get('status')} -> {msg.get('message')}" ) logger.error(err) sys.exit(1) - raise UnauthorizedError(f"{msg.get('status')} -> {msg.get('message')}") + break logger.info("PROVIDER INFO: %s", msg.get("message")) except Exception as e: logger.error("PROVIDER ERROR: %s -> %s", e.__class__.__name__, e.args[0]) @@ -113,7 +115,11 @@ async def login(websocket, api_key): async def subscribe(websocket, symbol, event): """Subscribe or unsubscribe to a symbol.""" - ticker = await handle_symbol(symbol) + try: + ticker = await handle_symbol(symbol) + except ValueError as e: + logger.error(e) + return subscribe_event = f'{{"action":"{event}","params":"{ticker}"}}' try: await websocket.send(subscribe_event) @@ -122,7 +128,7 @@ async def subscribe(websocket, symbol, event): logger.error(msg) -async def read_stdin_and_queue_commands(): +async def read_stdin(command_queue): """Read from stdin and queue commands.""" while True: line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) @@ -138,21 +144,31 @@ async def read_stdin_and_queue_commands(): logger.error("Invalid JSON received from stdin") +async def process_stdin_queue(websocket): + """Process the command queue.""" + while True: + command = await command_queue.dequeue() + symbol = command.get("symbol") + event = command.get("event") + if symbol and event: + await subscribe(websocket, symbol, event) + + async def process_message(message, results_path, table_name, limit): """Process the WebSocket message.""" messages = message if isinstance(message, list) else [message] for msg in messages: - if "Your plan doesn't include websocket access" in msg.get("message"): - err = f"UnauthorizedError -> {msg.get('message')}" - logger.error(err) - sys.exit(1) - raise UnauthorizedError(msg.get("message")) - if "status" in msg or "message" in msg: if "status" in msg and msg["status"] == "error": err = msg.get("message") raise websockets.WebSocketException(err) if "message" in msg and msg.get("message"): + if "Your plan doesn't include websocket access" in msg.get("message"): + err = f"UnauthorizedError -> {msg.get('message')}" + logger.error(err) + sys.exit(1) + break + logger.info("PROVIDER INFO: %s", msg.get("message")) elif msg and "ev" in msg and "status" not in msg: try: @@ -179,9 +195,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim lambda message: process_message(message, results_path, table_name, limit) ) ) - - stdin_task = asyncio.create_task(read_stdin_and_queue_commands()) - + stdin_task = asyncio.create_task(read_stdin(command_queue)) try: connect_kwargs = CONNECT_KWARGS.copy() if "ping_timeout" not in connect_kwargs: @@ -191,6 +205,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim try: async with websockets.connect(url, **connect_kwargs) as websocket: + await login(websocket, api_key) response = await websocket.recv() messages = json.loads(response) @@ -200,25 +215,22 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim messages = json.loads(response) await process_message(messages, results_path, table_name, limit) while True: - ws_task = asyncio.create_task(websocket.recv()) - cmd_task = asyncio.create_task(command_queue.dequeue()) - + cmd_task = asyncio.create_task(process_stdin_queue(websocket)) + msg_task = asyncio.create_task(websocket.recv()) done, pending = await asyncio.wait( - [ws_task, cmd_task], return_when=asyncio.FIRST_COMPLETED + [cmd_task, msg_task], + return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() for task in done: - if task == ws_task: + if task == cmd_task: + await cmd_task + elif task == msg_task: messages = task.result() await asyncio.shield(queue.enqueue(json.loads(messages))) - elif task == cmd_task: - command = task.result() - symbol = command.get("symbol") - event = command.get("event") - if symbol and event: - await subscribe(websocket, symbol, event) + except websockets.InvalidStatusCode as e: if e.status_code == 404: msg = f"PROVIDER ERROR: {e.__str__()}" @@ -254,7 +266,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim sys.exit(1) except Exception as e: - msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" + msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) sys.exit(1) From 4fcb48ead684c2c1973683204c825efb3587ec3f Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 10:43:08 -0800 Subject: [PATCH 18/40] fix fmp --- .../websockets/openbb_websockets/client.py | 22 ++-- .../openbb_websockets/websockets_router.py | 98 ++++++++------ .../extensions/websockets/pyproject.toml | 1 + .../openbb_fmp/models/websocket_connection.py | 21 +-- .../fmp/openbb_fmp/utils/websocket_client.py | 120 ++++++++++-------- .../models/websocket_connection.py | 12 +- 6 files changed, 158 insertions(+), 116 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 234a553e2d12..b9999f71ca87 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -199,6 +199,7 @@ def _log_provider_output(self, output_queue) -> None: output = output_queue.get(timeout=1) if output: # Handle raised exceptions from the provider connection thread, killing the process if required. + # UnauthorizedError should be raised by the parent thread, but we kill the process here. if "UnauthorizedError" in output: self._psutil_process.kill() self._process.wait() @@ -208,7 +209,9 @@ def _log_provider_output(self, output_queue) -> None: sys.stdout.write(output + "\n") sys.stdout.flush() break - + # ValidationError may occur after the provider connection is established. + # We write to stdout in case the exception can't be raised before the main function returns. + # We kill the connection here. if "ValidationError" in output: self._psutil_process.kill() self._process.wait() @@ -227,7 +230,14 @@ def _log_provider_output(self, output_queue) -> None: sys.stdout.write(msg + "\n") sys.stdout.flush() break - + # We don't kill the process on SymbolError, but raise the exception in the main thread instead. + # This is likely a subscribe event and the connection is already streaming. + if "SymbolError" in output: + err = ValueError(output) + self._exception = err + continue + # Other errors are logged to stdout and the process is killed. + # If the exception is raised by the parent thread, it will be treated as an unexpected error. if ( "server rejected" in output.lower() or "PROVIDER ERROR" in output @@ -241,11 +251,6 @@ def _log_provider_output(self, output_queue) -> None: sys.stdout.write(output + "\n") sys.stdout.flush() break - # We don't kill the process on SymbolError, but raise the exception in the main thread instead. - if "SymbolError" in output: - err = ValueError(output) - self._exception = err - continue output = clean_message(output) output = output + "\n" @@ -360,7 +365,8 @@ def connect(self) -> None: self._log_thread.daemon = True self._log_thread.start() - time.sleep(0.75) + # Give it some startup time to allow the connection to be establised and for exceptions to populate. + time.sleep(2) if self._exception is not None: exc = getattr(self, "_exception", None) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 1b9473436d66..8e93f5d80a33 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -55,9 +55,7 @@ async def create_connection( obbject = await OBBject.from_query(Query(**locals())) client = obbject.results.client - await asyncio.sleep(1) - - if not client.is_running or client._exception: + if not client.is_running or client._exception is not None: exc = getattr(client, "_exception", None) if exc: client._atexit() @@ -139,7 +137,7 @@ async def clear_results(name: str, auth_token: Optional[str] = None) -> OBBject[ ) async def subscribe( name: str, symbol: str, auth_token: Optional[str] = None -) -> OBBject[str]: +) -> OBBject[WebSocketConnectionStatus]: """Subscribe to a new symbol. Parameters @@ -153,8 +151,8 @@ async def subscribe( Returns ------- - str - The message that the client subscribed to the symbol. + WebSocketConnectionStatus + The status of the client connection. """ if not await check_auth(name, auth_token): raise OpenBBError("Error finding client.") @@ -170,8 +168,10 @@ async def subscribe( except OpenBBError as e: raise e from e + status = await get_status(name) + if client.is_running: - return OBBject(results=f"Added {symbol} to client {name} connection.") + return OBBject(results=WebSocketConnectionStatus(**status)) client.logger.error( f"Client {name} failed to subscribe to {symbol} and is not running." @@ -183,7 +183,7 @@ async def subscribe( ) async def unsubscribe( name: str, symbol: str, auth_token: Optional[str] = None -) -> OBBject[str]: +) -> OBBject[WebSocketConnectionStatus]: """Unsubscribe to a symbol. Parameters @@ -197,22 +197,23 @@ async def unsubscribe( Returns ------- - str - The message that the client unsubscribed from the symbol. + WebSocketConnectionStatus + The status of the client connection. """ if not await check_auth(name, auth_token): raise OpenBBError("Error finding client.") + client = connected_clients[name] symbols = client.symbol.split(",") + if symbol not in symbols: raise OpenBBError(f"Client {name} not subscribed to {symbol}.") + client.unsubscribe(symbol) - # await asyncio.sleep(2) - if client.is_running: - return OBBject(results=f"Client {name} unsubscribed to {symbol}.") - client.logger.error( - f"Client {name} failed to unsubscribe to {symbol} and is not running." - ) + + status = await get_status(name) + + return OBBject(results=WebSocketConnectionStatus(**status)) @router.command( @@ -230,7 +231,7 @@ async def get_client_status( Returns ------- - list[dict] + list[WebSocketConnectionStatus] The status of the client(s). """ if not connected_clients: @@ -272,7 +273,9 @@ async def get_client(name: str, auth_token: Optional[str] = None) -> OBBject: @router.command( methods=["GET"], ) -async def stop_connection(name: str, auth_token: Optional[str] = None) -> OBBject[str]: +async def stop_connection( + name: str, auth_token: Optional[str] = None +) -> OBBject[WebSocketConnectionStatus]: """Stop a the connection to the provider's websocket. Does not stop the broadcast server. Parameters @@ -284,16 +287,16 @@ async def stop_connection(name: str, auth_token: Optional[str] = None) -> OBBjec Returns ------- - str - The message that the provider connection was stopped. + WebSocketConnectionStatus + The status of the client connection. """ if not await check_auth(name, auth_token): raise OpenBBError("Error finding client.") client = connected_clients[name] client.disconnect() - return OBBject( - results=f"Client {name} connection to the provider's websocket was stopped." - ) + status = await get_status(name) + + return OBBject(results=WebSocketConnectionStatus(**status)) @router.command( @@ -301,7 +304,7 @@ async def stop_connection(name: str, auth_token: Optional[str] = None) -> OBBjec ) async def restart_connection( name: str, auth_token: Optional[str] = None -) -> OBBject[str]: +) -> OBBject[WebSocketConnectionStatus]: """Restart a websocket connection. Parameters @@ -313,16 +316,23 @@ async def restart_connection( Returns ------- - str - The message that the client connection was restarted. + WebSocketConnectionStatus + The status of the client connection. """ if name not in connected_clients: raise OpenBBError(f"No active client named, {name}. Use create_connection.") if not await check_auth(name, auth_token): raise OpenBBError("Error finding client.") client = connected_clients[name] - client.connect() - return OBBject(results=f"Client {name} connection was restarted.") + + try: + client.connect() + except OpenBBError as e: + raise e from e + + status = await get_status(name) + + return OBBject(results=WebSocketConnectionStatus(**status)) @router.command( @@ -330,7 +340,7 @@ async def restart_connection( ) async def stop_broadcasting( name: str, auth_token: Optional[str] = None -) -> OBBject[str]: +) -> OBBject[WebSocketConnectionStatus]: """Stop the broadcast server. Parameters @@ -342,8 +352,8 @@ async def stop_broadcasting( Returns ------- - str - The message that the client stopped broadcasting to the address. + WebSocketConnectionStatus + The status of the client connection. """ if name not in connected_clients: raise OpenBBError(f"Client {name} not connected.") @@ -356,7 +366,6 @@ async def stop_broadcasting( if not client.is_broadcasting: raise OpenBBError(f"Client {name} not broadcasting.") - old_address = client.broadcast_address client.stop_broadcasting() if not client.is_running: @@ -366,7 +375,9 @@ async def stop_broadcasting( results=f"Client {name} stopped broadcasting and was not running, client removed." ) - return OBBject(results=f"Client {name} stopped broadcasting to: {old_address}") + status = await get_status(name) + + return OBBject(results=WebSocketConnectionStatus(**status)) @router.command( @@ -378,7 +389,7 @@ async def start_broadcasting( host: str = "127.0.0.1", port: int = 6666, uvicorn_kwargs: Optional[dict[str, Any]] = None, -) -> OBBject[str]: +) -> OBBject[WebSocketConnectionStatus]: """Start broadcasting from a websocket. Parameters @@ -396,23 +407,27 @@ async def start_broadcasting( Returns ------- - str - The message that the client started broadcasting. + WebSocketConnectionStatus + The status of the client connection. """ if name not in connected_clients: raise OpenBBError(f"Client {name} not connected.") + if not await check_auth(name, auth_token): raise OpenBBError("Error finding client.") + client = connected_clients[name] kwargs = uvicorn_kwargs if uvicorn_kwargs else {} client.start_broadcasting(host=host, port=port, **kwargs) await asyncio.sleep(2) + if not client.is_broadcasting: raise OpenBBError(f"Client {name} failed to broadcast.") - return OBBject( - results=f"Client {name} started broadcasting to {client.broadcast_address}." - ) + + status = await get_status(name) + + return OBBject(results=WebSocketConnectionStatus(**status)) @router.command( @@ -435,9 +450,12 @@ async def kill(name: str, auth_token: Optional[str] = None) -> OBBject[str]: """ if not connected_clients: raise OpenBBError("No connections to kill.") - elif name and name not in connected_clients: + + if name and name not in connected_clients: raise OpenBBError(f"Client {name} not connected.") + client = connected_clients[name] client._atexit() del connected_clients[name] + return OBBject(results=f"Clients {name} killed.") diff --git a/openbb_platform/extensions/websockets/pyproject.toml b/openbb_platform/extensions/websockets/pyproject.toml index 62f5ca59fe06..c53ca99fd173 100644 --- a/openbb_platform/extensions/websockets/pyproject.toml +++ b/openbb_platform/extensions/websockets/pyproject.toml @@ -10,6 +10,7 @@ packages = [{ include = "openbb_websockets" }] [tool.poetry.dependencies] python = "^3.9" openbb-core = "^1.3.5" +aiosqlite = "^0.20.0" [build-system] requires = ["poetry-core"] diff --git a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py index 193990d280c8..4a5b43c93dc3 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py +++ b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py @@ -32,7 +32,7 @@ class FmpWebSocketQueryParams(WebSocketQueryParams): } symbol: str = Field( - description="The symbol(s) of the asset to fetch data for.", + description="The FMP symbol to get data for.", ) asset_type: Literal["stock", "fx", "crypto"] = Field( default="crypto", @@ -138,14 +138,14 @@ def transform_query(params: dict[str, Any]) -> FmpWebSocketQueryParams: return FmpWebSocketQueryParams(**params) @staticmethod - def extract_data( + async def aextract_data( query: FmpWebSocketQueryParams, credentials: Optional[dict[str, str]], **kwargs: Any, ) -> WebSocketClient: """Extract data from the WebSocket.""" # pylint: disable=import-outside-toplevel - import time + import asyncio api_key = credentials.get("fmp_api_key") if credentials else "" url = URL_MAP[query.asset_type] @@ -155,6 +155,7 @@ def extract_data( kwargs = { "url": url, "api_key": api_key, + "connect_kwargs": query.connect_kwargs, } client = WebSocketClient( @@ -175,17 +176,17 @@ def extract_data( try: client.connect() - - except Exception as e: # pylint: disable=broad-except - client.disconnect() - raise OpenBBError(e) from e - - time.sleep(1) + await asyncio.sleep(2) + if client._exception: + raise client._exception + except OpenBBError as e: + if client.is_running: + client.disconnect() + raise e from e if client.is_running: return client - client.disconnect() raise OpenBBError("Failed to connect to the WebSocket.") @staticmethod diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py index bbcdf57ff871..957e6257d122 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -13,14 +13,17 @@ MessageQueue, get_logger, handle_termination_signal, + handle_validation_error, parse_kwargs, write_to_db, ) +from pydantic import ValidationError logger = get_logger("openbb.websocket.fmp") kwargs = parse_kwargs() -queue = MessageQueue(max_size=kwargs.get("limit", 1000)) +queue = MessageQueue() command_queue = MessageQueue() +CONNECT_KWARGS = kwargs.pop("connect_kwargs", {}) async def login(websocket, api_key): @@ -32,17 +35,21 @@ async def login(websocket, api_key): } try: await websocket.send(json.dumps(login_event)) + await asyncio.sleep(1) response = await websocket.recv() - if json.loads(response).get("message") == "Unauthorized": + message = json.loads(response) + if message.get("message") == "Unauthorized": logger.error( - "PROVIDER ERROR: Account not authorized." + "UnauthorizedError -> Account not authorized." " Please check that the API key is entered correctly and is entitled to access." ) sys.exit(1) - msg = json.loads(response).get("message") - logger.info("PROVIDER INFO: %s", msg) - except Exception as e: - logger.error("PROVIDER ERROR: %s", e.args[0]) + else: + msg = message.get("message") + logger.info("PROVIDER INFO: %s", msg) + except Exception as e: # pylint: disable=broad-except + msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e.__str__()}" + logger.error(msg) sys.exit(1) @@ -57,8 +64,9 @@ async def subscribe(websocket, symbol, event): } try: await websocket.send(json.dumps(subscribe_event)) - except Exception as e: - msg = f"PROVIDER ERROR: {e}" + await asyncio.sleep(1) + except Exception as e: # pylint: disable=broad-except + msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e.__str__()}" logger.error(msg) @@ -75,28 +83,43 @@ async def read_stdin_and_queue_commands(): command = json.loads(line.strip()) await command_queue.enqueue(command) except json.JSONDecodeError: - logger.error("Invalid JSON received from stdin") + logger.error("Invalid JSON received from stdin -> %s", line.strip()) + + +async def process_stdin_queue(websocket): + """Process the command queue.""" + while True: + command = await command_queue.dequeue() + symbol = command.get("symbol") + event = command.get("event") + if symbol and event: + await subscribe(websocket, symbol, event) async def process_message(message, results_path, table_name, limit): - result = {} - message = json.loads(message) + """Process the message and write to the database.""" + result: dict = {} + message = json.loads(message) if isinstance(message, str) else message if message.get("event") != "heartbeat": if message.get("event") in ["login", "subscribe", "unsubscribe"]: - msg = f"PROVIDER INFO: {message.get('message')}" - logger.info(msg) - return None - try: - result = FmpWebSocketData.model_validate(message).model_dump_json( - exclude_none=True, exclude_unset=True - ) - except Exception as e: - msg = f"PROVIDER ERROR: Error validating data: {e}" - logger.error(msg) - return None - if result: - await write_to_db(result, results_path, table_name, limit) - return + if "you are not authorized" in message.get("message", "").lower(): + msg = f"UnauthorizedError -> FMP Message: {message['message']}" + logger.error(msg) + else: + msg = f"PROVIDER INFO: {message.get('message')}" + logger.info(msg) + else: + try: + result = FmpWebSocketData.model_validate(message).model_dump_json( + exclude_none=True, exclude_unset=True + ) + except ValidationError as e: + try: + handle_validation_error(logger, e) + except ValidationError: + raise e from e + if result: + await write_to_db(result, results_path, table_name, limit) async def connect_and_stream(url, symbol, api_key, results_path, table_name, limit): @@ -111,30 +134,26 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim stdin_task = asyncio.create_task(read_stdin_and_queue_commands()) try: - async with websockets.connect(url) as websocket: - await login(websocket, api_key) - await subscribe(websocket, symbol, "subscribe") + websocket = await websockets.connect(url) + await login(websocket, api_key) + await subscribe(websocket, symbol, "subscribe") - while True: - ws_task = asyncio.create_task(websocket.recv()) - cmd_task = asyncio.create_task(command_queue.dequeue()) + while True: + ws_task = asyncio.create_task(websocket.recv()) + cmd_task = asyncio.create_task(process_stdin_queue(websocket)) - done, pending = await asyncio.wait( - [ws_task, cmd_task], return_when=asyncio.FIRST_COMPLETED - ) - for task in pending: - task.cancel() - - for task in done: - if task == ws_task: - message = task.result() - await queue.enqueue(message) - elif task == cmd_task: - command = task.result() - symbol = command.get("symbol") - event = command.get("event") - if symbol and event: - await subscribe(websocket, symbol, event) + done, pending = await asyncio.wait( + [ws_task, cmd_task], return_when=asyncio.FIRST_COMPLETED + ) + for task in pending: + task.cancel() + + for task in done: + if task == cmd_task: + await cmd_task + elif task == ws_task: + message = task.result() + await asyncio.shield(queue.enqueue(json.loads(message))) except websockets.ConnectionClosed: logger.info("PROVIDER INFO: The WebSocket connection was closed.") @@ -144,11 +163,12 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim sys.exit(1) except Exception as e: - msg = f"PROVIDER ERROR: Unexpected error -> {e}" + msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) sys.exit(1) finally: + await websocket.close() handler_task.cancel() stdin_task.cancel() await asyncio.gather(handler_task, stdin_task, return_exceptions=True) @@ -180,7 +200,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim logger.error("PROVIDER ERROR: WebSocket connection closed") except Exception as e: # pylint: disable=broad-except - msg = f"PROVIDER ERROR: {e.args[0]}" + msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e.__str__()}" logger.error(msg) finally: diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 7cc4830b3e47..0cea4380e14e 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -1205,9 +1205,6 @@ def extract_data( **kwargs: Any, ) -> WebSocketClient: """Extract data from the WebSocket.""" - # pylint: disable=import-outside-toplevel - import time - api_key = credentials.get("polygon_api_key") if credentials else "" url = URL_MAP[query.asset_type] @@ -1239,11 +1236,10 @@ def extract_data( try: client.connect() - except Exception as e: - client.disconnect() - raise OpenBBError(e) from e - - time.sleep(1) + except OpenBBError as e: + if client.is_running: + client.disconnect() + raise e from e if client._exception: raise client._exception from client._exception From 47f10ef44aa5968b38c0c07c99672755c0ab7486 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 10:49:20 -0800 Subject: [PATCH 19/40] typo --- .../extensions/websockets/openbb_websockets/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index b9999f71ca87..a5ffca52aec5 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -365,7 +365,7 @@ def connect(self) -> None: self._log_thread.daemon = True self._log_thread.start() - # Give it some startup time to allow the connection to be establised and for exceptions to populate. + # Give it some startup time to allow the connection to be established and for exceptions to populate. time.sleep(2) if self._exception is not None: From cbbd16a2e94211eed43c751f7824f7781154256b Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 11:20:22 -0800 Subject: [PATCH 20/40] run _setup_database in a thread --- .../websockets/openbb_websockets/client.py | 64 +++++++++---------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index a5ffca52aec5..0d416d3d6f60 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -147,17 +147,8 @@ def __init__( # noqa: PLR0913 atexit.register(self._atexit) - # Set up the SQLite database and table. - # Loop handling is for when the class is used directly instead of from the app or API. try: - loop = asyncio.get_event_loop() - except (RuntimeError, RuntimeWarning): - loop = asyncio.new_event_loop() - try: - if loop.is_running(): - loop.create_task(self._setup_database()) - else: - asyncio.run(self._setup_database()) + self._setup_database() except DatabaseError as e: self.logger.error("Error setting up the SQLite database and table: %s", e) @@ -177,12 +168,38 @@ def _atexit(self) -> None: if os.path.exists(self.results_file): os.remove(self.results_file) - async def _setup_database(self) -> None: + def _setup_database(self) -> None: """Set up the SQLite database and table.""" # pylint: disable=import-outside-toplevel + import asyncio # noqa + import threading from openbb_websockets.helpers import setup_database - return await setup_database(self.results_path, self.table_name) + def run_in_new_loop(): + """Run setup in new event loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete( + setup_database(self.results_path, self.table_name) + ) + finally: + loop.close() + + def run_in_thread(): + """Run setup in separate thread.""" + thread = threading.Thread(target=run_in_new_loop) + thread.start() + thread.join() + + try: + try: + loop = asyncio.get_running_loop() # noqa + run_in_thread() + except RuntimeError: + run_in_new_loop() + finally: + return def _log_provider_output(self, output_queue) -> None: """Log output from the provider logger, handling exceptions, errors, and messages that are not data.""" @@ -485,34 +502,13 @@ def results(self): """Clear results stored from the WebSocket stream.""" # pylint: disable=import-outside-toplevel import sqlite3 # noqa - import asyncio - import threading - - def run_in_new_loop(): - """Run setup in new event loop.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._setup_database()) - finally: - loop.close() - - def run_in_thread(): - """Run setup in separate thread.""" - thread = threading.Thread(target=run_in_new_loop) - thread.start() - thread.join() try: with sqlite3.connect(self.results_path) as conn: conn.execute(f"DELETE FROM {self.table_name}") # noqa conn.commit() - try: - loop = asyncio.get_running_loop() # noqa - run_in_thread() - except RuntimeError: - run_in_new_loop() + self._setup_database() self.logger.info( "Results cleared from table %s in %s", From 6b640df493377477fa97cbed141460ec2233e02b Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:35:47 -0800 Subject: [PATCH 21/40] fix some tiingo weirdness --- .../models/websocket_connection.py | 67 +++++++++------ .../openbb_tiingo/utils/websocket_client.py | 82 +++++++++++-------- 2 files changed, 89 insertions(+), 60 deletions(-) diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py index 7291d1cd24ce..0aef53649aca 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py @@ -14,7 +14,7 @@ WebSocketData, WebSocketQueryParams, ) -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator URL_MAP = { "stock": "wss://api.tiingo.com/iex", @@ -89,25 +89,22 @@ class TiingoWebSocketQueryParams(WebSocketQueryParams): } symbol: str = Field( - description=QUERY_DESCRIPTIONS.get("symbol", "") + "Use '*' for all symbols.", + description=QUERY_DESCRIPTIONS.get("symbol", "") + " Use '*' for all symbols.", ) asset_type: Literal["stock", "fx", "crypto"] = Field( default="crypto", - description="The asset type for the feed.", + description="The asset type for the feed. Choices are 'stock', 'fx', or 'crypto'.", ) feed: Literal["trade", "trade_and_quote"] = Field( default="trade_and_quote", - description="The type of data feed to subscribe to. FX only supports quote.", + description="The type of data feed to subscribe to. FX only supports quote." + + " Choices are 'trade' or 'trade_and_quote'.", ) class TiingoWebSocketData(WebSocketData): """Tiingo WebSocket data model.""" - timestamp: Optional[int] = Field( - default=None, - description="Nanoseconds since POSIX time UTC.", - ) type: Literal["quote", "trade", "break"] = Field( description="The type of data.", ) @@ -175,25 +172,35 @@ def _valiidate_data_type(cls, v): "quote" if v == "Q" else "trade" if v == "T" else "break" if v == "B" else v ) - @field_validator("date", mode="before", check_fields=False) + @field_validator("date", "timestamp", mode="before", check_fields=False) def _validate_date(cls, v): """Validate the date.""" # pylint: disable=import-outside-toplevel + from pandas import to_datetime from pytz import timezone if isinstance(v, str): - return datetime.fromisoformat(v) - try: - return datetime.fromtimestamp(v / 1000) - except Exception: - if isinstance(v, (int, float)): - # Check if the timestamp is in nanoseconds and convert to seconds - if v > 1e12: - v = v / 1e9 # Convert nanoseconds to seconds - dt = datetime.fromtimestamp(v) - dt = timezone("America/New_York").localize(dt) - return dt - return v + dt = to_datetime(v, utc=True).tz_convert(timezone("America/New_York")) + else: + try: + dt = datetime.fromtimestamp(v / 1000) + except Exception: + if isinstance(v, (int, float)): + # Check if the timestamp is in nanoseconds and convert to seconds + if v > 1e12: + v = v / 1e9 # Convert nanoseconds to seconds + dt = datetime.fromtimestamp(v) + else: + dt = v + + return dt + + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + + return values class TiingoWebSocketConnection(WebSocketConnection): @@ -244,6 +251,7 @@ async def aextract_data( "url": url, "api_key": api_key, "threshold_level": threshold_level, + "connect_kwargs": query.connect_kwargs, } client = WebSocketClient( @@ -264,13 +272,18 @@ async def aextract_data( try: client.connect() - # Unhandled exceptions are caught and raised as OpenBBError - except Exception as e: # pylint: disable=broad-except - client.disconnect() - raise OpenBBError(e) from e + except OpenBBError as e: + if client.is_running: + client.disconnect() + raise e from e + + await sleep(1) - # Wait for the connection to be established before returning. - await sleep(2) + if client._exception: + exc = getattr(client, "_exception", None) + client._exception = None + client._atexit() + raise OpenBBError(exc) if client.is_running: return client diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py index 5c31928890b3..cf256c146a0e 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py @@ -13,9 +13,11 @@ MessageQueue, get_logger, handle_termination_signal, + handle_validation_error, parse_kwargs, write_to_db, ) +from pydantic import ValidationError # These are the data array definitions. IEX_FIELDS = [ @@ -66,10 +68,11 @@ "ask_size", "ask_price", ] -subscription_id = None +SUBSCRIPTION_ID = "" queue = MessageQueue() logger = get_logger("openbb.websocket.tiingo") kwargs = parse_kwargs() +CONNECT_KWARGS = kwargs.pop("connect_kwargs", {}) # Subscribe and unsubscribe events are handled in a separate connection using the subscription_id set by the login event. @@ -77,7 +80,7 @@ async def update_symbols(symbol, event): """Update the symbols to subscribe to.""" url = kwargs["url"] - if not subscription_id: + if not SUBSCRIPTION_ID: logger.error( "PROVIDER ERROR: Must be assigned a subscription ID to update symbols. Try logging in." ) @@ -87,7 +90,7 @@ async def update_symbols(symbol, event): "eventName": event, "authorization": kwargs["api_key"], "eventData": { - "subscriptionId": subscription_id, + "subscriptionId": SUBSCRIPTION_ID, "tickers": symbol, }, } @@ -96,13 +99,10 @@ async def update_symbols(symbol, event): await websocket.send(json.dumps(update_event)) response = await websocket.recv() message = json.loads(response) - if message.get("response", {}).get("code") != 200: - logger.error(f"PROVIDER ERROR: {message}") - else: - msg = ( - f"PROVIDER INFO: {message.get('response', {}).get('message')}. " - f"Subscribed to symbols: {message.get('data', {}).get('tickers')}" - ) + if "tickers" in message.get("data", {}): + tickers = message["data"]["tickers"] + threshold_level = message["data"].get("thresholdLevel") + msg = f"PROVIDER INFO: Subscribed to {tickers} with threshold level {threshold_level}" logger.info(msg) @@ -124,10 +124,11 @@ async def read_stdin_and_update_symbols(): async def process_message(message, results_path, table_name, limit): - result = {} - data_message = {} - message = json.loads(message) - msg = "" + """Process the message and write to the database.""" + result: dict = {} + data_message: dict = {} + message = message if isinstance(message, (dict, list)) else json.loads(message) + msg: str = "" if message.get("messageType") == "E": response = message.get("response", {}) msg = f"PROVIDER ERROR: {response.get('code')}: {response.get('message')}" @@ -147,20 +148,21 @@ async def process_message(message, results_path, table_name, limit): msg = f"PROVIDER INFO: Authorization: {response.get('message')}" logger.info(msg) if message.get("data", {}).get("subscriptionId"): - global subscription_id + global SUBSCRIPTION_ID + SUBSCRIPTION_ID = message["data"]["subscriptionId"] - subscription_id = message["data"]["subscriptionId"] + if "tickers" in response.get("data", {}): + tickers = message["data"]["tickers"] + threshold_level = message["data"].get("thresholdLevel") + msg = f"PROVIDER INFO: Subscribed to {tickers} with threshold level {threshold_level}" + logger.info(msg) - if "tickers" in message.get("data", {}): - tickers = message["data"]["tickers"] - threshold_level = message["data"].get("thresholdLevel") - msg = f"PROVIDER INFO: Subscribed to {tickers} with threshold level {threshold_level}" - logger.info(msg) elif message.get("messageType") == "A": data = message.get("data", []) service = message.get("service") if service == "iex": data_message = {IEX_FIELDS[i]: data[i] for i in range(len(data))} + _ = data_message.pop("timestamp", None) elif service == "fx": data_message = {FX_FIELDS[i]: data[i] for i in range(len(data))} elif service == "crypto_data": @@ -179,10 +181,12 @@ async def process_message(message, results_path, table_name, limit): result = TiingoWebSocketData.model_validate(data_message).model_dump_json( exclude_none=True, exclude_unset=True ) - except Exception as e: - msg = f"PROVIDER ERROR: Error validating data: {e}" - logger.error(msg) - return + except ValidationError as e: + try: + handle_validation_error(logger, e) + except ValidationError: + raise e from e + if result: await write_to_db(result, results_path, table_name, limit) return @@ -207,33 +211,45 @@ async def connect_and_stream( subscribe_event = { "eventName": "subscribe", "authorization": api_key, - "eventData": {"thresholdLevel": threshold_level, "tickers": ticker}, + "eventData": { + "thresholdLevel": threshold_level, + "tickers": ticker, + }, } + connect_kwargs = CONNECT_KWARGS.copy() + if "ping_timeout" not in connect_kwargs: + connect_kwargs["ping_timeout"] = None + if "close_timeout" not in connect_kwargs: + connect_kwargs["close_timeout"] = None + try: - async with websockets.connect( - url, ping_interval=20, ping_timeout=20, max_queue=1000 - ) as websocket: + async with websockets.connect(url, **connect_kwargs) as websocket: logger.info("PROVIDER INFO: WebSocket connection established.") await websocket.send(json.dumps(subscribe_event)) while True: message = await websocket.recv() await queue.enqueue(message) + except UnauthorizedError as e: + logger.error(str(e)) + sys.exit(1) + except websockets.ConnectionClosed as e: msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e.reason}" logger.info(msg) # Attempt to reopen the connection + logger.info("PROVIDER INFO: Attempting to reconnect after five seconds...") await asyncio.sleep(5) await connect_and_stream( url, symbol, threshold_level, api_key, results_path, table_name, limit ) except websockets.WebSocketException as e: - logger.error(e) + logger.error(str(e)) sys.exit(1) except Exception as e: - msg = f"PROVIDER ERROR: Unexpected error -> {e}" + msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) sys.exit(1) @@ -268,10 +284,10 @@ async def connect_and_stream( loop.run_forever() except (KeyboardInterrupt, websockets.ConnectionClosed): - logger.error("PROVIDER ERROR: WebSocket connection closed") + logger.error("PROVIDER ERROR: WebSocket connection closed") except Exception as e: # pylint: disable=broad-except - msg = f"PROVIDER ERROR: {e.args[0]}" + msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) finally: From 8712e0569fe35e8cefd46702772d2cfee515e918 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:59:57 -0800 Subject: [PATCH 22/40] raise exc as OpenBBError in client.connect --- .../extensions/websockets/openbb_websockets/client.py | 3 ++- .../openbb_polygon/models/websocket_connection.py | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 0d416d3d6f60..72a720b3ce5d 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -328,6 +328,7 @@ def connect(self) -> None: import subprocess import threading import time + from openbb_core.app.model.abstract.error import OpenBBError if self.is_running: self.logger.info("Provider connection already running.") @@ -388,7 +389,7 @@ def connect(self) -> None: if self._exception is not None: exc = getattr(self, "_exception", None) self._exception = None - raise exc + raise OpenBBError(exc) if not self.is_running: self.logger.error("The provider server failed to start.") diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 0cea4380e14e..c333ddfaf167 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -650,7 +650,9 @@ class PolygonStockTradeWebSocketData(WebSocketData): "trf_id": "trfi", "tape": "z", "price": "p", + "size": "s", "conditions": "c", + "trf_timestamp": "trft", } type: str = Field( @@ -658,7 +660,7 @@ class PolygonStockTradeWebSocketData(WebSocketData): ) date: datetime = Field( description=DATA_DESCRIPTIONS.get("date", "") - + "The end of the aggregate window.", + + "The SIP timestamp of the trade.", ) symbol: str = Field( description=DATA_DESCRIPTIONS.get("symbol", ""), @@ -667,6 +669,9 @@ class PolygonStockTradeWebSocketData(WebSocketData): description="The price of the trade.", json_schema_extra={"x-unit_measurement": "currency"}, ) + size: float = Field( + description="The size of the trade.", + ) exchange: str = Field( description="The exchange where the trade originated.", ) @@ -677,7 +682,7 @@ class PolygonStockTradeWebSocketData(WebSocketData): default=None, description="The conditions of the trade.", ) - trf_id: Optional[str] = Field( + trf_id: Optional[int] = Field( default=None, description="The ID for the Trade Reporting Facility where the trade took place.", ) From 95a1356c8c852bbf700a514c203bfb2885113a03 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:18:16 -0800 Subject: [PATCH 23/40] trade size is optional --- .../openbb_polygon/models/websocket_connection.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index c333ddfaf167..989a135097fa 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -343,7 +343,8 @@ class PolygonCryptoTradeWebSocketData(WebSocketData): description="The price of the trade.", json_schema_extra={"x-unit_measurement": "currency"}, ) - size: float = Field( + size: Optional[float] = Field( + default=None, description="The size of the trade.", ) @@ -669,7 +670,8 @@ class PolygonStockTradeWebSocketData(WebSocketData): description="The price of the trade.", json_schema_extra={"x-unit_measurement": "currency"}, ) - size: float = Field( + size: Optional[float] = Field( + default=None, description="The size of the trade.", ) exchange: str = Field( @@ -958,7 +960,8 @@ class PolygonOptionsTradeWebSocketData(WebSocketData): description="The price of the trade.", json_schema_extra={"x-unit_measurement": "currency"}, ) - size: float = Field( + size: Optional[float] = Field( + default=None, description="The size of the trade.", ) exchange: str = Field( From 2e4d6930717b03baf2a85c793b917126dc30c469 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 11 Nov 2024 23:34:26 -0800 Subject: [PATCH 24/40] start some documentation --- .../extensions/websockets/README.md | 472 ++++++++++++++++++ .../websockets/openbb_websockets/broadcast.py | 2 +- .../openbb_websockets/websockets_router.py | 3 +- .../models/websocket_connection.py | 7 + .../openbb_polygon/utils/websocket_client.py | 2 +- .../openbb_tiingo/utils/websocket_client.py | 11 +- 6 files changed, 490 insertions(+), 7 deletions(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index e69de29bb2d1..9f2895d6ef71 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -0,0 +1,472 @@ +# OpenBB WebSockets Toolkit + +At the application/API level, the user does not directly interact with the client, or provider stream. +Connections are established as background tasks, and there are not any direct methods for blocking the main thread and command line. + + +## Endpoints + +The extension creates a new router path from the application base - `obb.websockets`, api/v1/websockets for the API. + +Endpoints are for managing the life cycle of one or more provider websocket connections. + +```python +from openbb import obb + +obb.websockets +``` + +```sh +/websockets + clear_results + create_connection + get_client # Not included in API + get_client_status + get_results + kill + restart_connection + start_broadcasting + stop_broadcasting + stop_connection + subscribe + unsubscribe +``` + +> Except for, `get_results`, functions do not return the data or stream. Outputs will be a WebSocketConnectionStatus instance, or a string message. +> All functions, except `create_connection`, assume that a connection has already been establiehd and are referenced by parameters: +> +> |Parameter|Type | Required| Description | +> |:-------|:-----|:--------:|------------:| +> |name |String |Yes |The 'nane' assigned from `create_connection` | +> |auth_token |String |No |The 'auth_token' assigned, if any, from `create_connection` | +> +> Below is an explanation of each function, with `create_connection` representing the bulk of details. + +### create_connection + +All other endpoints require this to be used first. It is the only function mapping to the Provider Interface, and is used to establish a new connection. + +#### Standard Parameters + +|Parameter|Type | Required| Description | +|:-------|:-----|:--------:|------------:| +|provider |String |Yes |Name of the provider - i.e, `"polygon"`, `"fmp"`, `"tiingo"` | +|name |String |Yes |Name to assign the connection. This is the 'name' parameter in the other endpoints.| +|auth_token |String |No |When supplied, the same token must be passed for all future interactions with the connection, or to read from the broadcast server. | +|results_file |String |No |Absolute path to the file for continuous writing. Temp file is created by default. Unless 'save_results' is True, discarded on exit. | +|save_results |Boolean |No |Whether to persist the file after the session ends, default is `False` | +|table_name |String |No |Name of the SQL table to write the results to, consisting of an auto-increment ID and a serialized JSON string of the data. Default is `"records"`| +|limit |Integer |No |Maximum number of records to store in the 'results_file', set as `None` to retain all data messages. Default is `1000`| +|sleep_time |Float |No |Does not impact the provider connection. Time, in seconds, to sleep between checking for new records, default is `0.25` | +|broadcast_host |String |No |IP address for running the broadcast server, default is `"127.0.0.1"` | +|broadcast_port |Integer |No |Port number to bind the broadcasat server to, default is `6666` | +|start_broadcast |Boolean |No |Whether to start the broadcast server immediately, default is `False` | +|connect_kwargs |Dictionary |No |Keyword arguments to pass directly to `websockets.connect()` in the provider module. Also accepts a serialized JSON string dictionary. | + + +#### Provider-Specific Parameters + +Other parameters will be specific to the provider, but there may be common ground. Refer to the function's docstring for more detail. +The table below is not intended as a source of truth. + +|Parameter|Type | Required| Description | +|:-------|:-----|:--------:|------------:| +|symbol |String |Yes |The ticker symbol for the asset - i.e, `"aapl"`, `"usdjpy"`, `"dogeusd"`, `"btcusd,ethusd"`, `"*"`| +|asset_type |String |Yes |The asset type associated with the 'symbol'. Choices vary by provider, but typically include [`"stock"`, `"fx"`, `"crypto"`] | +|feed |String |No |The particular feed to subscribe to, if available. Choices vary by provider, but might include [`"trade"`, `"quote"`] | + +Availability will depend on the access level permitted by the provider's API key. + +#### Usage + +```python +conn = obb.websockets.create_connection(provider="tiingo", asset_type="crypto", symbol="*", feed="trade", start_broadcast=True) + +conn +``` + +```sh +PROVIDER INFO: WebSocket connection established. + +PROVIDER INFO: Authorization: Success + +BROADCAST INFO: Stream results from ws://127.0.0.1:6666 + +OBBject[T] + +id: 06732d37-fe11-744c-8000-072414ba1cdd +results: {'name': 'crypto_tiingo', 'auth_required': False, 'subscribed_symbols': '*... +provider: tiingo +warnings: None +chart: None +extra: {'metadata': {'arguments': {'provider_choices': {'provider': 'tiingo'}, 'sta... +``` + +```python +conn.results.model_dump() +``` + +```sh +{'name': 'crypto_tiingo', + 'auth_required': False, + 'subscribed_symbols': '*', + 'is_running': True, + 'provider_pid': 5810, + 'is_broadcasting': True, + 'broadcast_address': 'ws://127.0.0.1:6666', + 'broadcast_pid': 5813, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpwb4jslbg', + 'table_name': 'records', + 'save_results': False} +``` + +All of the currently captured data can be dumped with the `get_results` endpoint. The return will be the typical data response object. + +```python +obb.websockets.get_results("crypto_tiingo").to_df().iloc[-5:] +``` + +| date | symbol | type | exchange | last_price | last_size | +|:---------------------------------|:---------|:-------|:-----------|-------------:|------------:| +| 2024-11-11 23:13:19.753398-05:00 | gfiusd | trade | gdax | 1.89012 | 257.12 | +| 2024-11-11 23:13:19.757000-05:00 | ondousdt | trade | mexc | 0.930851 | 3508.35 | +| 2024-11-11 23:13:19.760000-05:00 | neousdt | trade | huobi | 12.31 | 13.9489 | +| 2024-11-11 23:13:19.793594-05:00 | xrpusd | trade | gdax | 0.60433 | 4676.46 | +| 2024-11-11 23:13:19.819856-05:00 | xlmusd | trade | gdax | 0.11446 | 120.088 | + +#### Listen + +Listen to the stream by opening another terminal window and importing the `listen` function. + +> Using this function within the same session is not recommended because `ctrl-c` will stop the provider and broadcast servers without properly terminating the processes. When this happens, use the `kill` endpoint to finish the job. + + +```python +from openbb_websockets.listen import listen + +listen("ws://127.0.0.1:6666") +``` + +```sh +Listening for messages from ws://127.0.0.1:6666 + +{"date":"2024-11-11T23:51:33.083000-05:00","symbol":"klvusdt","type":"trade","exchange":"huobi","last_price":0.00239,"last_size":8367.4749} + +{"date":"2024-11-11T23:51:33.082000-05:00","symbol":"actsolusdt","type":"trade","exchange":"huobi","last_price":0.5837245604964619,"last_size":1070.2939999999999} +... +``` + +Opening a listener will notify the main thread: + +```sh +BROADCAST INFO: ('127.0.0.1', 59197) - "WebSocket /" [accepted] + +BROADCAST INFO: connection open + +BROADCAST INFO: connection closed +``` + +The provider connection can be stopped and restarted without disrupting the broadcast server. +The broadcast server can be terminated without stopping the provider connection. + + +### clear_results + +Clears the items written to `results_file`. The connection can be running or stopped and does not terminate writing or reading. + +#### Example + +```python +obb.websockets.clear_results("crypto_tiingo") +``` + +```sh +Results cleared from table records in /var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpwb4jslbg + +OBBject[T] + +id: 06732ed2-a72c-758e-8000-b7943259f615 +results: 1001 results cleared from crypto_tiingo. +provider: None +warnings: None +chart: None +extra: {'metadata': {'arguments': {'provider_choices': {}, 'standard_params': {}, '... +``` + + +### get_client + +> Not available from the API. + +This returns the `WebSocketClient` object, and the provider client can be controlled directly as a Python object. Refer to the [Development](README.md#development) section for a detailed explanation of this class. + +#### Example + +```python +client = obb.websockets.get_client("crypto_tiingo").results +``` + +```sh +WebSocketClient(module=['/Users/someuser/miniconda3/envs/obb/bin/python', '-m', 'openbb_tiingo.utils.websocket_client'], symbol=*, is_running=True, provider_pid: 7125, is_broadcasting=True, broadcast_address=ws://127.0.0.1:6666, broadcast_pid: 7128, results_file=/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpwb4jslbg, table_name=records, save_results=False) +``` + +```python +print(client.is_running) +client.disconnect() +print(client.is_running) +``` + +```sh +True +Disconnected from the provider WebSocket. +False +``` + +### get_client_status + +Get the current status of an initialized WebSocketConnection. + +#### Example + +```python +obb.websockets.get_client_status("all").to_dict("records") +``` + +```sh +[{'name': 'crypto_tiingo', + 'auth_required': False, + 'subscribed_symbols': '*', + 'is_running': False, + 'provider_pid': None + 'is_broadcasting': True, + 'broadcast_address': 'ws://127.0.0.1:6666', + 'broadcast_pid': 7723, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpup7zd_uu', + 'table_name': 'records', + 'save_results': False}, + {'name': 'fx_polygon', + 'auth_required': False, + 'subscribed_symbols': '*', + 'is_running': True, + 'provider_pid': 7773} + 'is_broadcasting': False, + 'broadcast_address': None, + 'broadcast_pid': None, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpzs6of15g', + 'table_name': 'records', + 'save_results': False] +``` + +### get_results + +Get the captured records in the `results_file`. + +```python +obb.websockets.get_results("fx_polygon").to_dict("records")[-1] +``` + +```sh +{'date': Timestamp('2024-11-12 01:41:03-0500', tz='UTC-05:00'), + 'symbol': 'CAD/SGD', + 'type': 'C', + 'exchange': 'Currency Banks 1', + 'bid': 0.958360440227192, + 'ask': 0.958407631503548} +``` + +### kill + +Terminate a connection and all of its processes. + +#### Example + +```python +obb.websockets.kill("fx_polygon") +``` + +```sh +Disconnected from the provider WebSocket. + +OBBject[T] + +id: 06732fa3-1df8-7d82-8000-b492686a1b8b +results: Clients fx_polygon killed. +provider: None +warnings: None +chart: None +extra: {'metadata': {'arguments': {'provider_choices': {}, 'standard_params': {}, '... +``` + +### restart_connection + +Restart a connection after running `stop_connection`. + +#### Example + +```python +obb.websockets.restart_connection("crypto_tiingo").results.model_dump() +``` + +```sh +PROVIDER INFO: WebSocket connection established. + +PROVIDER INFO: Authorization: Success + +{'name': 'crypto_tiingo', + 'auth_required': False, + 'subscribed_symbols': '*', + 'is_running': True, + 'provider_pid': 7939, + 'is_broadcasting': True, + 'broadcast_address': 'ws://127.0.0.1:6666', + 'broadcast_pid': 7723, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpup7zd_uu', + 'table_name': 'records', + 'save_results': False} +``` + +### start_broadcasting + +Start the broadcast server. + +#### Additional Parameters + +|Parameter|Type | Required| Description | +|:-------|:-----|:--------:|------------:| +|host |String |No |IP address to run the server over, default is `"127.0.0.1"` | +|port |Interger |No |Port to bind the server to, default is `6666` | +|uvicorn_kwargs| Dictionary |No |Additional keyword arguments to pass directly to `uvicorn.run()`. | + +#### Example + +```python +obb.websockets.start_broadcasting("crypto_tiingo").results +``` + +```sh +BROADCAST INFO: Stream results from ws://127.0.0.1:6666 + +WebSocketConnectionStatus(name=crypto_tiingo, auth_required=False, subscribed_symbols=*, is_running=True, provider_pid=7939, is_broadcasting=True, broadcast_address=ws://127.0.0.1:6666, broadcast_pid=8080, results_file=/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpup7zd_uu, table_name=records, save_results=False) +``` + +### stop_broadcasting + +Stop the broadcast server. + +#### Example + +```python +obb.websockets.stop_broadcasting("crypto_tiingo").results.model_dump() +``` + +```sh +Stopped broadcasting to: ws://127.0.0.1:6666 + +{'name': 'crypto_tiingo', + 'auth_required': False, + 'subscribed_symbols': '*', + 'is_running': True, + 'provider_pid': 7939, + 'is_broadcasting': False, + 'broadcast_address': None, + 'broadcast_pid': None, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpup7zd_uu', + 'table_name': 'records', + 'save_results': False} +``` + +### stop_connection + +Stop the provider websocket connection. + +#### Example + +```python +obb.websockets.stop_connection("crypto_tiingo").results.model_dump() +``` + +```sh +Disconnected from the provider WebSocket. + +{'name': 'crypto_tiingo', + 'auth_required': False, + 'subscribed_symbols': '*', + 'is_running': False, + 'provider_pid': None, + 'is_broadcasting': False, + 'broadcast_address': None, + 'broadcast_pid': None, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpup7zd_uu', + 'table_name': 'records', + 'save_results': False} +``` + +### subscribe + +Subscribe to a new symbol(s). Enter multiple symbols as a comma-seperated string. + +#### Example + +```python +obb.websockets.subscribe("fx_polygon", symbol="xauusd") +``` + +```sh +PROVIDER INFO: subscribed to: C.XAU/USD + +OBBject[T] + +id: 06733025-a43d-71ed-8000-981ec3cfb697 +results: {'name': 'fx_polygon', 'auth_required': False, 'subscribed_symbols': 'EURU... +provider: None +warnings: None +chart: None +extra: {'metadata': {'arguments': {'provider_choices': {}, 'standard_params': {}, '... +``` + +### unsubscribe + +Unsubscribe from a symbol(s) + +#### Example + +```python +obb.websockets.unsubscribe("fx_polygon", symbol="xauusd").results.model_dump() +``` + +```sh +PROVIDER INFO: unsubscribed to: C.XAU/USD + +{'name': 'fx_polygon', + 'auth_required': False, + 'subscribed_symbols': 'EURUSD', + 'is_running': True, + 'provider_pid': 8582, + 'is_broadcasting': False, + 'broadcast_address': None, + 'broadcast_pid': None, + 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmp1z70a3fw', + 'table_name': 'records', + 'save_results': False} +``` + + + +## Development + +The Python interface and Fast API endpoints are built with importable components that can be used independently of the application. + + +### WebSocketClient + +#### Import + +This is the client used for bidirectional communication with both, the provider connection, and, the broadcast server. + +```python +from openbb_websockets.client import WebSocketClient +``` + + + +...tbc \ No newline at end of file diff --git a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py index 29bc7b739b31..cabca40c8829 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py @@ -105,7 +105,7 @@ async def websocket_endpoint( # noqa: PLR0915 await stream_task await stdin_task except asyncio.CancelledError: - broadcast_server.logger.info("Stream task cancelled") + broadcast_server.logger.info("INFO: A listener task was cancelled.") except Exception as e: msg = f"Unexpected error while cancelling stream task: {e.__class__.__name__} -> {e}" broadcast_server.logger.error(msg) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 8e93f5d80a33..d5317bea3733 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -66,6 +66,7 @@ async def create_connection( if hasattr(extra_params, "start_broadcast") and extra_params.start_broadcast: client.start_broadcasting() + await asyncio.sleep(1) client_name = client.name connected_clients[client_name] = client @@ -403,7 +404,7 @@ async def start_broadcasting( port : int The port to broadcast to. Default is 6666. uvicorn_kwargs : Optional[dict[str, Any]] - Additional keyword arguments for passing directly to the uvicorn server. + Additional keyword arguments to pass directly to `uvicorn.run()`. Returns ------- diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 989a135097fa..7aafbc6689ba 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -550,6 +550,13 @@ def _validate_exchange(cls, v): """Validate the exchange.""" return FX_EXCHANGE_MAP.get(v, str(v)) + @model_validator(mode="before") + @classmethod + def _validate_model(cls, values): + """Validate the model.""" + _ = values.pop("i", None) + return values + class PolygonStockAggsWebSocketData(WebSocketData): """Polygon Stock Aggregates WebSocket data model.""" diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index 74b2e428c39e..e22421abe2bd 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -65,7 +65,7 @@ async def handle_symbol(symbol): if ASSET_TYPE == "crypto" and "-" not in ticker and "*" not in ticker: ticker = ticker[:-3] + "-" + ticker[-3:] elif ASSET_TYPE == "fx" and "/" not in ticker and "*" not in ticker: - ticker = ticker[:3] + "/" + ticker[3:] + ticker = ticker[:-3] + "/" + ticker[-3:] elif ASSET_TYPE == "fx" and "-" in ticker: ticker = ticker.replace("-", "/") elif ( diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py index cf256c146a0e..cc2e06841262 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py @@ -235,7 +235,9 @@ async def connect_and_stream( sys.exit(1) except websockets.ConnectionClosed as e: - msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e.reason}" + msg = ( + f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" + ) logger.info(msg) # Attempt to reopen the connection logger.info("PROVIDER INFO: Attempting to reconnect after five seconds...") @@ -266,8 +268,7 @@ async def connect_and_stream( loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, handle_termination_signal, logger) + loop.add_signal_handler(signal.SIGTERM, handle_termination_signal, logger) asyncio.run_coroutine_threadsafe( connect_and_stream( @@ -283,7 +284,7 @@ async def connect_and_stream( ) loop.run_forever() - except (KeyboardInterrupt, websockets.ConnectionClosed): + except websockets.ConnectionClosed: logger.error("PROVIDER ERROR: WebSocket connection closed") except Exception as e: # pylint: disable=broad-except @@ -291,4 +292,6 @@ async def connect_and_stream( logger.error(msg) finally: + loop.stop() + loop.close sys.exit(0) From b69cba831bf17a6a5ccfaeea7fc67996f27abcfe Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:35:41 -0800 Subject: [PATCH 25/40] make bid/ask optional in PolygonStockQuoteWebSocketData --- openbb_platform/extensions/websockets/README.md | 2 +- .../models/websocket_connection.py | 17 +++++++++-------- .../polygon/openbb_polygon/utils/constants.py | 2 ++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index 9f2895d6ef71..5d83dc925ff9 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -77,7 +77,7 @@ The table below is not intended as a source of truth. Availability will depend on the access level permitted by the provider's API key. -#### Usage +#### Example ```python conn = obb.websockets.create_connection(provider="tiingo", asset_type="crypto", symbol="*", feed="trade", start_broadcast=True) diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 7aafbc6689ba..d792f2606535 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -8,10 +8,7 @@ from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.provider.abstract.data import Data from openbb_core.provider.abstract.fetcher import Fetcher -from openbb_core.provider.utils.descriptions import ( - DATA_DESCRIPTIONS, - QUERY_DESCRIPTIONS, -) +from openbb_core.provider.utils.descriptions import DATA_DESCRIPTIONS from openbb_polygon.utils.constants import ( CRYPTO_EXCHANGE_MAP, FX_EXCHANGE_MAP, @@ -775,18 +772,22 @@ class PolygonStockQuoteWebSocketData(WebSocketData): bid_exchange: str = Field( description="The exchange where the bid originated.", ) - bid_size: float = Field( + bid_size: Optional[float] = Field( + default=None, description="The size of the bid.", ) - bid: float = Field( + bid: Optional[float] = Field( + default=None, description="The bid price.", json_schema_extra={"x-unit_measurement": "currency"}, ) - ask: float = Field( + ask: Optional[float] = Field( + default=None, description="The ask price.", json_schema_extra={"x-unit_measurement": "currency"}, ) - ask_size: float = Field( + ask_size: Optional[float] = Field( + default=None, description="The size of the ask.", ) ask_exchange: str = Field( diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py index 9b827c091161..41ab54d99f13 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py @@ -155,9 +155,11 @@ 90: "Syndicate Bid", 91: "Pre Syndicate Bid", 92: "Penalty Bid", + 95: "CQS Generated", } STOCK_QUOTE_INDICATORS = { + 1: "EXCHANGE_ACQUISITION", 601: "NBBO_NO_CHANGE", 602: "NBBO_QUOTE_IS_NBBO", 603: "NBBO_NO_BB_NO_BO", From e93ab2ffd961e4336a7a649b91e70a41bcbd730a Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:37:35 -0800 Subject: [PATCH 26/40] got some missing stock quote indicator definitions --- .../openbb_fmp/models/websocket_connection.py | 19 +++++++++------ .../polygon/openbb_polygon/utils/constants.py | 9 ++++++- .../models/websocket_connection.py | 5 ++++ .../openbb_tiingo/utils/websocket_client.py | 24 +++++++++---------- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py index 4a5b43c93dc3..68d662e4a16a 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py +++ b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py @@ -69,24 +69,24 @@ class FmpWebSocketData(WebSocketData): type: Literal["quote", "trade", "break"] = Field( description="The type of data.", ) - bid_price: Optional[float] = Field( - default=None, - description="The price of the bid.", - json_schema_extra={"x-unit_measurement": "currency"}, - ) bid_size: Optional[float] = Field( default=None, description="The size of the bid.", ) - ask_size: Optional[float] = Field( + bid_price: Optional[float] = Field( default=None, - description="The size of the ask.", + description="The price of the bid.", + json_schema_extra={"x-unit_measurement": "currency"}, ) ask_price: Optional[float] = Field( default=None, description="The price of the ask.", json_schema_extra={"x-unit_measurement": "currency"}, ) + ask_size: Optional[float] = Field( + default=None, + description="The size of the ask.", + ) last_price: Optional[float] = Field( default=None, description="The last trade price.", @@ -97,6 +97,11 @@ class FmpWebSocketData(WebSocketData): description="The size of the trade.", ) + @field_validator("symbol", mode="before") + def _validate_symbol(cls, v): + """Validate the symbol.""" + return v.upper() + @field_validator("type", mode="before", check_fields=False) def _valiidate_data_type(cls, v): """Validate the data type.""" diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py index 41ab54d99f13..5501c44ee13a 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/constants.py @@ -159,7 +159,14 @@ } STOCK_QUOTE_INDICATORS = { - 1: "EXCHANGE_ACQUISITION", + 1: "LULD_NBB_NBO_EXECUTABLE", + 22: "LULD_REPUBLISHED_LULD_PRICE_BAND", + 52: "FINANCIAL_STATUS_DEFICIENT", + 301: "SHORT_SALES_RESTRICTION_ACTIVATED", + 302: "SHORT_SALES_RESTRICTION_CONTINUED", + 303: "SHORT_SALES_RESTRICTION_DEACTIVATED", + 304: "SHORT_SALES_RESTRICTION_IN_EFFECT", + 305: "SHORT_SALES_RESTRICTION_MAX", 601: "NBBO_NO_CHANGE", 602: "NBBO_QUOTE_IS_NBBO", 603: "NBBO_NO_BB_NO_BO", diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py index 0aef53649aca..3b083645952d 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py @@ -165,6 +165,11 @@ class TiingoWebSocketData(WebSocketData): description="True if the order is not subject to NMS Rule 611. Only for stock.", ) + @field_validator("symbol", mode="before", check_fields=False) + def _validate_symbol(cls, v): + """Validate the symbol.""" + return v.upper() + @field_validator("type", mode="before", check_fields=False) def _valiidate_data_type(cls, v): """Validate the data type.""" diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py index cc2e06841262..b93aa3287209 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py @@ -223,16 +223,17 @@ async def connect_and_stream( connect_kwargs["close_timeout"] = None try: - async with websockets.connect(url, **connect_kwargs) as websocket: - logger.info("PROVIDER INFO: WebSocket connection established.") - await websocket.send(json.dumps(subscribe_event)) - while True: - message = await websocket.recv() - await queue.enqueue(message) - - except UnauthorizedError as e: - logger.error(str(e)) - sys.exit(1) + try: + async with websockets.connect(url, **connect_kwargs) as websocket: + logger.info("PROVIDER INFO: WebSocket connection established.") + await websocket.send(json.dumps(subscribe_event)) + while True: + message = await websocket.recv() + await queue.enqueue(message) + + except UnauthorizedError as e: + logger.error(str(e)) + sys.exit(1) except websockets.ConnectionClosed as e: msg = ( @@ -284,9 +285,6 @@ async def connect_and_stream( ) loop.run_forever() - except websockets.ConnectionClosed: - logger.error("PROVIDER ERROR: WebSocket connection closed") - except Exception as e: # pylint: disable=broad-except msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" logger.error(msg) From 8e6f776147549338288a1a513a5ef8011fb0bfe1 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:07:48 -0800 Subject: [PATCH 27/40] readme file with too much info --- .../extensions/websockets/README.md | 404 +++++++++++++++++- .../providers/fmp/openbb_fmp/__init__.py | 4 +- .../openbb_fmp/models/websocket_connection.py | 21 +- .../fmp/openbb_fmp/utils/websocket_client.py | 2 +- .../openbb_tiingo/utils/websocket_client.py | 2 +- 5 files changed, 410 insertions(+), 23 deletions(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index 5d83dc925ff9..80f515fc1b42 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -454,19 +454,413 @@ PROVIDER INFO: unsubscribed to: C.XAU/USD ## Development -The Python interface and Fast API endpoints are built with importable components that can be used independently of the application. +### Provider Interface -### WebSocketClient +Providers can be added to the `create_connection` endpoint by following a slightly modified pattern. +This section outlines the adaptations, but does not contain any code for actually connecting to the provider's websocket. +For details on that part, go to [websocket_client](README.md###websocket_client) section below. -#### Import -This is the client used for bidirectional communication with both, the provider connection, and, the broadcast server. +Here, the Fetcher is used to start the provider client module (in a separate file) and return the client to the router, where it is intercepted and kept alive. + +> The provider client is not returned to the user, only its status. + +In the provider's "/models" folder, we need a file, `my_provider_websoccket_connection.py`, and it will layout nearly the same as any other provider model. + +We will create one additional model, `WebSocketConnection`, which has only one inherited field, 'client', and no other fields are permitted. This is what gets returned to the router. + +We also need another file, in the `utils` folder, `websocket_client.py`. + +Creating the QueryParams and Data models will be in the same style as all the other models, name it 'websocket_connection.py'. + +#### WebSocketQueryParams ```python +"""FMP WebSocket model.""" + +from datetime import datetime +from typing import Any, Literal, Optional + +from openbb_core.app.model.abstract.error import OpenBBError +from openbb_core.provider.abstract.fetcher import Fetcher from openbb_websockets.client import WebSocketClient +from openbb_websockets.models import ( + WebSocketConnection, + WebSocketData, + WebSocketQueryParams, +) +from pydantic import Field, field_validator + +URL_MAP = { + "stock": "wss://websockets.financialmodelingprep.com", + "fx": "wss://forex.financialmodelingprep.com", + "crypto": "wss://crypto.financialmodelingprep.com", +} + + +class FmpWebSocketQueryParams(WebSocketQueryParams): + """FMP WebSocket query parameters.""" + + __json_schema_extra__ = { + "symbol": {"multiple_items_allowed": True}, + "asset_type": { + "multiple_items_allowed": False, + "choices": ["stock", "fx", "crypto"], + }, + } + + symbol: str = Field( + description="The FMP symbol to get data for.", + ) + asset_type: Literal["stock", "fx", "crypto"] = Field( + default="crypto", + description="The asset type, required for the provider URI.", + ) +``` + +#### WebSocketData + +```python +class FmpWebSocketData(WebSocketData): + """FMP WebSocket data model.""" + + __alias_dict__ = { + "symbol": "s", + "date": "t", + "exchange": "e", + "type": "type", + "bid_size": "bs", + "bid_price": "bp", + "ask_size": "as", + "ask_price": "ap", + "last_price": "lp", + "last_size": "ls", + } + + exchange: Optional[str] = Field( + default=None, + description="The exchange of the data.", + ) + type: Literal["quote", "trade", "break"] = Field( + description="The type of data.", + ) + bid_size: Optional[float] = Field( + default=None, + description="The size of the bid.", + ) + bid_price: Optional[float] = Field( + default=None, + description="The price of the bid.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_price: Optional[float] = Field( + default=None, + description="The price of the ask.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + ask_size: Optional[float] = Field( + default=None, + description="The size of the ask.", + ) + last_price: Optional[float] = Field( + default=None, + description="The last trade price.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + last_size: Optional[float] = Field( + default=None, + description="The size of the trade.", + ) + + @field_validator("symbol", mode="before") + def _validate_symbol(cls, v): + """Validate the symbol.""" + return v.upper() + + @field_validator("type", mode="before", check_fields=False) + def _valiidate_data_type(cls, v): + """Validate the data type.""" + return ( + "quote" if v == "Q" else "trade" if v == "T" else "break" if v == "B" else v + ) + + @field_validator("date", mode="before", check_fields=False) + def _validate_date(cls, v): + """Validate the date.""" + # pylint: disable=import-outside-toplevel + from pytz import timezone + + if isinstance(v, str): + dt = datetime.fromisoformat(v) + try: + dt = datetime.fromtimestamp(v / 1000) + except Exception: # pylint: disable=broad-except + if isinstance(v, (int, float)): + # Check if the timestamp is in nanoseconds and convert to seconds + if v > 1e12: + v = v / 1e9 # Convert nanoseconds to seconds + dt = datetime.fromtimestamp(v) + + return dt.astimezone(timezone("America/New_York")) +``` + +#### WebSocketConnection + +This model is what we return from the `FmpWebSocketFetcher`. + + +```python +class FmpWebSocketConnection(WebSocketConnection): + """FMP WebSocket connection model.""" +``` + +#### WebSocketFetcher + +This is where things diverge slightly. Instead of returning `FmpWebSocketData`, we will pass it to the client connection insteadd, for validating records as they are received. What gets returned by the Fetcher is the `WebSocketConnection`. + +```python +class FmpWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnection]): + """FMP WebSocket model.""" + + @staticmethod + def transform_query(params: dict[str, Any]) -> FmpWebSocketQueryParams: + """Transform the query parameters.""" + return FmpWebSocketQueryParams(**params) + + @staticmethod + async def aextract_data( + query: FmpWebSocketQueryParams, + credentials: Optional[dict[str, str]], + **kwargs: Any, + ) -> WebSocketClient: + """Extract data from the WebSocket.""" + # pylint: disable=import-outside-toplevel + import asyncio + + api_key = credentials.get("fmp_api_key") if credentials else "" + url = URL_MAP[query.asset_type] + + symbol = query.symbol.lower() + + # Arrange a dictionary of parameters that will be passed to the client connection. + kwargs = { + "url": url, + "api_key": api_key, + "connect_kwargs": query.connect_kwargs, # Pass custom parameters to `websockets.connect()` + } + + # The object to be returned. Everything the provider client thread needs to know is in this instance. + client = WebSocketClient( + name=query.name, + module="openbb_fmp.utils.websocket_client", # This is the file with the client connection that gets run as a script. + symbol=symbol, + limit=query.limit, + results_file=query.results_file, + table_name=query.table_name, + save_results=query.save_results, + data_model=FmpWebSocketData, # WebSocketDataModel goes here. + sleep_time=query.sleep_time, + broadcast_host=query.broadcast_host, + broadcast_port=query.broadcast_port, + auth_token=query.auth_token, + **kwargs, + ) + + # Start the client thread, give it a moment to startup and check for exceptions. + try: + client.connect() + await asyncio.sleep(2) + # Exceptions are triggered from the stdout reader and are converted + # to a Python Exception that gets stored here. + # If an exception was caught and the connection failed, we catch it here. + # They may not have raised yet, and it will be checked again further down. + if client._exception: + raise client._exception + # Everything caught gets raised as an OpenBBError, we catch those. + except OpenBBError as e: + if client.is_running: + client.disconnect() + raise e from e + # Check if the process is still running before returning. + if client.is_running: + return client + + raise OpenBBError("Failed to connect to the WebSocket.") + + @staticmethod + def transform_data( + data: WebSocketClient, + query: FmpWebSocketQueryParams, + **kwargs: Any, + ) -> FmpWebSocketConnection: + """Return the client as an instance of Data.""" + # All we need to do here is return our client wrapped in the WebSocketConnection class. + return FmpWebSocketConnection(client=data) +``` + +#### Map To Router + +Map the new fetcher in the provider's `__init__.py` file by adding it to the `fetcher_dict`. + +```python +"WebSocketConnection": FmpWebSocketFetcher ``` +Assuming the communication with `websocket_client` is all in order, it will be ready-to-go as a `provider` to the `create_connection` endpoint. + +### websocket_client + +This is the file where all the action happens. It receives subscribe/unsubscribe events, writes records to the `results_file`, and returns info and error messages to the main application thread. + +Some components are importable, but variances between providers require some localized solutions. They will be similar, but not 100% repeatable. + +#### Imports: + +```python +import asyncio +import json +import os +import signal +import sys + +import websockets +import websockets.exceptions +from openbb_fmp.models.websocket_connection import FmpWebSocketData # Import the data model that was created in the 'websocket_connection' file. +from openbb_websockets.helpers import ( + MessageQueue, + get_logger, + handle_termination_signal, + handle_validation_error, + parse_kwargs, + write_to_db, +) +from pydantic import ValidationError +``` + +#### `parse_kwargs` + +This function converts the keyword arguments passed at the time of launch. It should be run at the top of the file, with the global constants. + +```python +kwargs = parse_kwargs() +``` + +The dictionary will have all the parameters needed to establish the connection, and the instructions for where to record the results. + + +#### `get_logger` + +This function creates a logger instance with a unique name, configured to the INFO level, with a new line break between messages. +The logger is used to communicate information and errors back to the main application. + +> Only pass non-data messages and errors to the logger. + +Create the logger after the import section. + +```python +logger = get_logger("openbb.websocket.fmp") # A UUID gets attached to the name so multiple instances of the script do not initialize the same logger. +``` + +#### `MessageQueue` + +This is an async Queue with an input for the message handler. Create a second instance if a separate queue is required for the subscibe events. + +Define your async message handler function, and create a task to run in the main event loop. + +```python + +# At the top with the `logger` +queue = MessageQueue() + + +# This goes right before the `websockets.connect` code. +handler_task = asyncio.create_task( + queue.process_queue( + lambda message: process_message(message, results_path, table_name, limit) + ) +) +``` + +The queue can also be dequeued manually. + +```python +message = await queue.dequeue() +``` + +#### `handle_validation_error` + +Before submitting the record to `write_to_db`, validate and transform the data with the WebSocketData that was created and imported. Use this function right before transmission, a failure will trigger a termination signal from the main application. + +```python +# code above confirms that the message being processed is a data message and not an info message or error. + + try: + result = FmpWebSocketData.model_validate(message).model_dump_json( + exclude_none=True, exclude_unset=True + ) + except ValidationError as e: + try: + handle_validation_error(logger, e) + except ValidationError: + raise e from e + if result: + await write_to_db(result, results_path, table_name, limit) + +``` + + +#### `write_to_db` + +This function is responsible for recording the data message to the `results_file`, and will be used in the message handler. + +The inputs are all positional arguments, and aside from `message`, are in the `kwargs` dictionary and were supplied during the initialization of `WebSocketClient` in the provider's Fetcher. + + +```python +results_path = os.path.abspath(kwargs.get("results_file")) +table_name = kwargs.get("table_name") +limit = kwargs.get("limit") + +await write_to_db(message, results_path, table_name, limit) +``` + +#### `handle_termination_signal` + +Simple function, that triggers `sys.exit(0)` with a message, for use in `loop.add_signal_handler`. + +```python +if __name__ == "__main__": + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_termination_signal, logger) + + # asyncio.run_coroutine_threadsafe(some_connect_and_stream_function, loop) + # loop.run_forever() +... +``` + +#### To-Build + +THe missing pieces that get created locally include: + +- Read `stdin` function for receiving subscribe/unsubscribe events while the connection is running. + - Messages to handle will always have the same format: `'{"event": "subscribe", "symbol": ticker}'` + - Converting for the symbology used by the provider needs to happen here. + - Implementation depends on the requirements of the provider - i.e, how to structure send events. + - Create the task before the initial `websockets.connect` block. + +- Initial login event, the `api_key` will be included in the `kwargs` dictionary, if required. + - This event might need to happen before a subscribe event, handle any custom messages before entering the `while True` block. + - `UnauthorizedError` is raised by sending a `logger.error()` that begins with "UnauthorizedError -> %s". +- Message Handler + - This is the handler task that reads the message queue and determines where to send the message, database or logger. + - If the message is a row of data, send it to `write_to_db`. Else, send it back to the main application via: + - `logger.info("PROVIDER INFO: %s", message.get('message'))` + - Raise the message as an unexpected error: + - `logger.error("Unexpected error -> %s", message.get('message'))` -...tbc \ No newline at end of file diff --git a/openbb_platform/providers/fmp/openbb_fmp/__init__.py b/openbb_platform/providers/fmp/openbb_fmp/__init__.py index c2531817f93d..93b225cd1a12 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/__init__.py +++ b/openbb_platform/providers/fmp/openbb_fmp/__init__.py @@ -63,7 +63,7 @@ from openbb_fmp.models.risk_premium import FMPRiskPremiumFetcher from openbb_fmp.models.share_statistics import FMPShareStatisticsFetcher from openbb_fmp.models.treasury_rates import FMPTreasuryRatesFetcher -from openbb_fmp.models.websocket_connection import FMPWebSocketFetcher +from openbb_fmp.models.websocket_connection import FmpWebSocketFetcher from openbb_fmp.models.world_news import FMPWorldNewsFetcher from openbb_fmp.models.yield_curve import FMPYieldCurveFetcher @@ -138,7 +138,7 @@ "TreasuryRates": FMPTreasuryRatesFetcher, "WorldNews": FMPWorldNewsFetcher, "EtfHistorical": FMPEquityHistoricalFetcher, - "WebSocketConnection": FMPWebSocketFetcher, + "WebSocketConnection": FmpWebSocketFetcher, "YieldCurve": FMPYieldCurveFetcher, }, repr_name="Financial Modeling Prep (FMP)", diff --git a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py index 68d662e4a16a..b157e572548a 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py +++ b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py @@ -36,7 +36,7 @@ class FmpWebSocketQueryParams(WebSocketQueryParams): ) asset_type: Literal["stock", "fx", "crypto"] = Field( default="crypto", - description="The asset type, required for the provider URI.", + description="The asset type associated with the symbol.", ) @@ -56,12 +56,6 @@ class FmpWebSocketData(WebSocketData): "last_size": "ls", } - symbol: str = Field( - description="The symbol of the asset.", - ) - date: datetime = Field( - description="The datetime of the data.", - ) exchange: Optional[str] = Field( default=None, description="The exchange of the data.", @@ -116,25 +110,24 @@ def _validate_date(cls, v): from pytz import timezone if isinstance(v, str): - return datetime.fromisoformat(v) + dt = datetime.fromisoformat(v) try: - return datetime.fromtimestamp(v / 1000) - except Exception: + dt = datetime.fromtimestamp(v / 1000) + except Exception: # pylint: disable=broad-except if isinstance(v, (int, float)): # Check if the timestamp is in nanoseconds and convert to seconds if v > 1e12: v = v / 1e9 # Convert nanoseconds to seconds dt = datetime.fromtimestamp(v) - dt = timezone("America/New_York").localize(dt) - return dt - return v + + return dt.astimezone(timezone("America/New_York")) class FmpWebSocketConnection(WebSocketConnection): """FMP WebSocket connection model.""" -class FMPWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnection]): +class FmpWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnection]): """FMP WebSocket model.""" @staticmethod diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py index 957e6257d122..d9f902c42ab3 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -134,7 +134,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim stdin_task = asyncio.create_task(read_stdin_and_queue_commands()) try: - websocket = await websockets.connect(url) + websocket = await websockets.connect(url, **CONNECT_KWARGS) await login(websocket, api_key) await subscribe(websocket, symbol, "subscribe") diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py index b93aa3287209..50ca68c09c19 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py @@ -148,7 +148,7 @@ async def process_message(message, results_path, table_name, limit): msg = f"PROVIDER INFO: Authorization: {response.get('message')}" logger.info(msg) if message.get("data", {}).get("subscriptionId"): - global SUBSCRIPTION_ID + global SUBSCRIPTION_ID # noqa: PLW0603 SUBSCRIPTION_ID = message["data"]["subscriptionId"] if "tickers" in response.get("data", {}): From 34dc7b40d86be4a9a13615aa82cd39265fccc583 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:36:50 -0800 Subject: [PATCH 28/40] typo --- openbb_platform/extensions/websockets/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index 80f515fc1b42..b6b23247b001 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -845,7 +845,7 @@ if __name__ == "__main__": #### To-Build -THe missing pieces that get created locally include: +The missing pieces that get created locally include: - Read `stdin` function for receiving subscribe/unsubscribe events while the connection is running. - Messages to handle will always have the same format: `'{"event": "subscribe", "symbol": ticker}'` From 133cb7218f6bb4e84964f069573c6683dfea72f5 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:49:31 -0800 Subject: [PATCH 29/40] another typo --- openbb_platform/extensions/websockets/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index b6b23247b001..f1a400d5aa49 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -617,7 +617,7 @@ class FmpWebSocketConnection(WebSocketConnection): #### WebSocketFetcher -This is where things diverge slightly. Instead of returning `FmpWebSocketData`, we will pass it to the client connection insteadd, for validating records as they are received. What gets returned by the Fetcher is the `WebSocketConnection`. +This is where things diverge slightly. Instead of returning `FmpWebSocketData`, it gets passed to the client connection for validating records as they are received. What gets returned by the Fetcher is the `WebSocketConnection`. ```python class FmpWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnection]): From 4f5a14b61587789839901187de57590ab1c08d51 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:58:27 -0800 Subject: [PATCH 30/40] typo --- openbb_platform/extensions/websockets/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index f1a400d5aa49..1ac672498ce9 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -37,7 +37,7 @@ obb.websockets > > |Parameter|Type | Required| Description | > |:-------|:-----|:--------:|------------:| -> |name |String |Yes |The 'nane' assigned from `create_connection` | +> |name |String |Yes |The 'name' assigned from `create_connection` | > |auth_token |String |No |The 'auth_token' assigned, if any, from `create_connection` | > > Below is an explanation of each function, with `create_connection` representing the bulk of details. @@ -864,3 +864,4 @@ The missing pieces that get created locally include: - Raise the message as an unexpected error: - `logger.error("Unexpected error -> %s", message.get('message'))` +> With all the functions built, the file should run as a script where keyword arguments are formatted as `key=value`, with a space between each pair. From 31b05c0c15581b99aae6428befd88d966f993bf3 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:38:20 -0800 Subject: [PATCH 31/40] fix discriminator tag issue --- .../websockets/openbb_websockets/helpers.py | 10 ++- .../websockets/openbb_websockets/models.py | 83 +++++++++++-------- .../openbb_websockets/websockets_router.py | 9 +- .../fmp/openbb_fmp/utils/websocket_client.py | 11 ++- .../models/websocket_connection.py | 2 +- 5 files changed, 68 insertions(+), 47 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index 21539d192087..d8cedfcba97a 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -3,7 +3,7 @@ import logging import re import sys -from typing import Optional +from typing import Any, Optional from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.provider.utils.errors import UnauthorizedError @@ -46,11 +46,13 @@ def handle_validation_error(logger: logging.Logger, error: ValidationError): raise error from error -async def get_status(name: str) -> dict: +async def get_status(name: Optional[str] = None, client: Optional[Any] = None) -> dict: """Get the status of a client.""" - if name not in connected_clients: + if name and name not in connected_clients: raise OpenBBError(f"Client {name} not connected.") - client = connected_clients[name] + if not name and not client: + raise OpenBBError("Either name or client must be provided.") + client = client if client else connected_clients[name] provider_pid = client._psutil_process.pid if client.is_running else None broadcast_pid = ( client._psutil_broadcast_process.pid if client.is_broadcasting else None diff --git a/openbb_platform/extensions/websockets/openbb_websockets/models.py b/openbb_platform/extensions/websockets/openbb_websockets/models.py index 48ca4266595e..8e53e72233ed 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/models.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/models.py @@ -8,9 +8,8 @@ from openbb_core.provider.abstract.query_params import QueryParams from openbb_core.provider.utils.descriptions import ( DATA_DESCRIPTIONS, - QUERY_DESCRIPTIONS, ) -from pydantic import ConfigDict, Field, field_validator +from pydantic import ConfigDict, Field, field_validator, model_validator from openbb_websockets.client import WebSocketClient @@ -89,39 +88,6 @@ def _validate_connect_kwargs(cls, v): return json.dumps(v, separators=(",", ":")) -class WebSocketData(Data): - """WebSocket data model.""" - - date: datetime = Field( - description=DATA_DESCRIPTIONS.get("date", ""), - ) - symbol: str = Field( - description=DATA_DESCRIPTIONS.get("symbol", ""), - ) - - -class WebSocketConnection(Data): - """Data model for returning WebSocketClient from the Provider Interface.""" - - __model_config__ = ConfigDict( - extra="forbid", - ) - - client: Any = Field( - description="Instance of WebSocketClient class initialized by a provider Fetcher." - + " The client is used to communicate with the provider's data stream." - + " It is not returned to the user, but is handled by the router for API access.", - exclude=True, - ) - - @field_validator("client", mode="before", check_fields=False) - def _validate_client(cls, v): - """Validate the client.""" - if not isinstance(v, WebSocketClient): - raise ValueError("Client must be an instance of WebSocketClient.") - return v - - class WebSocketConnectionStatus(Data): """Data model for WebSocketConnection status information.""" @@ -164,3 +130,50 @@ class WebSocketConnectionStatus(Data): save_results: bool = Field( description="Whether to save the results after the session ends.", ) + + +class WebSocketData(Data): + """WebSocket data model.""" + + date: datetime = Field( + description=DATA_DESCRIPTIONS.get("date", ""), + ) + symbol: str = Field( + description=DATA_DESCRIPTIONS.get("symbol", ""), + ) + + +class WebSocketConnection(Data): + """Data model for returning WebSocketClient from the Provider Interface.""" + + __model_config__ = ConfigDict( + extra="forbid", + ) + + client: Optional[Any] = Field( + default=None, + description="Instance of WebSocketClient class initialized by a provider Fetcher." + + " The client is used to communicate with the provider's data stream." + + " It is not returned to the user, but is handled by the router for API access.", + exclude=True, + ) + status: Optional[WebSocketConnectionStatus] = Field( + default=None, + description="Status information for the WebSocket connection.", + ) + + @field_validator("client", mode="before", check_fields=False) + @classmethod + def _validate_client(cls, v): + """Validate the client.""" + if v and not isinstance(v, WebSocketClient): + raise ValueError("Client must be an instance of WebSocketClient.") + return v + + @model_validator(mode="before") + @classmethod + def _validate_inputs(cls, vaules): + """Validate the status.""" + if not vaules.get("status") and not vaules.get("client"): + raise ValueError("Cannot initialize empty.") + return vaules diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index d5317bea3733..5445e0e1b6a4 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -39,8 +39,9 @@ async def create_connection( provider_choices: ProviderChoices, standard_params: StandardParams, extra_params: ExtraParams, -) -> OBBject[WebSocketConnectionStatus]: +) -> OBBject: """Create a new provider websocket connection.""" + name = extra_params.name if name in connected_clients: broadcast_address = connected_clients[name].broadcast_address @@ -70,10 +71,8 @@ async def create_connection( client_name = client.name connected_clients[client_name] = client - results = await get_status(client_name) - - obbject.results = WebSocketConnectionStatus(**results) - + status = await get_status(client_name) + obbject.results.status = WebSocketConnectionStatus(**status) return obbject diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py index d9f902c42ab3..b54890874bc8 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -155,8 +155,15 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim message = task.result() await asyncio.shield(queue.enqueue(json.loads(message))) - except websockets.ConnectionClosed: - logger.info("PROVIDER INFO: The WebSocket connection was closed.") + except websockets.ConnectionClosed as e: + msg = ( + f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" + ) + logger.info(msg) + # Attempt to reopen the connection + logger.info("PROVIDER INFO: Attempting to reconnect after five seconds.") + await asyncio.sleep(5) + await connect_and_stream(url, symbol, api_key, results_path, table_name, limit) except websockets.WebSocketException as e: logger.error(e) diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index d792f2606535..0960bb4850db 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -137,7 +137,7 @@ class PolygonWebSocketQueryParams(WebSocketQueryParams): } symbol: str = Field( - description="Polygon symbol to get data for." + description="\n Polygon symbol to get data for." + " All feeds, except Options, support the wildcard symbol, '*', for all symbols." + "\n Options symbols are the OCC contract symbol and support up to 1000 individual contracts" + " per connection. Crypto and FX symbols should be entered as a pair, i.e., 'BTCUSD', 'JPYUSD'." From b10d64034a68e8bc94dadd2b5dd41b5f770ed131 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 14 Nov 2024 22:50:04 -0800 Subject: [PATCH 32/40] add integration tests --- .../extensions/websockets/README.md | 8 +- .../integration/test_websockets_api.py | 376 +++++++++++++++++ .../integration/test_websockets_python.py | 388 ++++++++++++++++++ .../extensions/websockets/tests/__init__.py | 1 + 4 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 openbb_platform/extensions/websockets/integration/test_websockets_api.py create mode 100644 openbb_platform/extensions/websockets/integration/test_websockets_python.py create mode 100644 openbb_platform/extensions/websockets/tests/__init__.py diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index 1ac672498ce9..b98a11e2aafc 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -607,8 +607,14 @@ class FmpWebSocketData(WebSocketData): #### WebSocketConnection -This model is what we return from the `FmpWebSocketFetcher`. +This model is what we return from the `FmpWebSocketFetcher`. The provider will inherit two fields, only the `client` needs to be included on output. No additional fields should be defined. +- client + - Instance of WebSocketClient, not returned to from the Router. +- status + - Leave empty, the Router fills this and returns it. + +The model will not accept additional fields. ```python class FmpWebSocketConnection(WebSocketConnection): diff --git a/openbb_platform/extensions/websockets/integration/test_websockets_api.py b/openbb_platform/extensions/websockets/integration/test_websockets_api.py new file mode 100644 index 000000000000..047ff50f2efa --- /dev/null +++ b/openbb_platform/extensions/websockets/integration/test_websockets_api.py @@ -0,0 +1,376 @@ +"""Test WebSockets API Integration.""" + +import base64 + +import pytest +import requests +from extensions.tests.conftest import parametrize +from openbb_core.env import Env +from openbb_core.provider.utils.helpers import get_querystring + + +@pytest.fixture(scope="session") +def headers(): + """Get the headers for the API request.""" + userpass = f"{Env().API_USERNAME}:{Env().API_PASSWORD}" + userpass_bytes = userpass.encode("ascii") + base64_bytes = base64.b64encode(userpass_bytes) + + return {"Authorization": f"Basic {base64_bytes.decode('ascii')}"} + + +# pylint: disable=redefined-outer-name + + +@parametrize( + "params", + [ + ( + { + "name": "test_fmp", + "provider": "fmp", + "symbol": "btcusd,dogeusd", + "asset_type": "crypto", + "auth_token": None, + "results_file": None, + "save_results": False, + "table_name": "records", + "limit": 10, + "sleep_time": 0.25, + "broadcast_host": "localhost", + "broadcast_port": 6666, + "start_broadcast": False, + "connect_kwargs": None, + } + ), + ( + { + "name": "test_tiingo", + "provider": "tiingo", + "symbol": "btcusd,dogeusd", + "asset_type": "crypto", + "feed": "trade_and_quote", + "auth_token": None, + "results_file": None, + "save_results": False, + "table_name": "records", + "limit": 10, + "sleep_time": 0.25, + "broadcast_host": "localhost", + "broadcast_port": 6666, + "start_broadcast": False, + "connect_kwargs": None, + } + ), + ( + { + "name": "test_polygon", + "provider": "polygon", + "symbol": "btcusd,dogeusd", + "asset_type": "crypto", + "feed": "quote", + "auth_token": None, + "results_file": None, + "save_results": False, + "table_name": "records", + "limit": 10, + "sleep_time": 0.25, + "broadcast_host": "localhost", + "broadcast_port": 6666, + "start_broadcast": False, + "connect_kwargs": None, + } + ), + ], +) +@pytest.mark.integration +def test_websockets_create_connection(params, headers): + """Test the websockets_create_connection endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/create_connection?{query_str}" + + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + res = result.json()["results"] + assert isinstance(res, dict) + assert res.get("status", {}).get("is_running") + assert not res.get("status", {}).get("is_broadcasting") + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_get_results(params, headers): + """Test the websockets_get_results endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/get_results?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_clear_results(params, headers): + """Test the websockets_clear_results endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/clear_results?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "symbol": "ethusd", + "auth_token": None, + }, + { + "name": "test_tiingo", + "symbol": "ethusd", + "auth_token": None, + }, + { + "name": "test_polygon", + "symbol": "ethusd", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_subscribe(params, headers): + """Test the websockets_subscribe endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/subscribe?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + "host": None, + "port": None, + "uvicorn_kwargs": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + "host": None, + "port": None, + "uvicorn_kwargs": None, + }, + { + "name": "test_polygon", + "auth_token": None, + "host": None, + "port": None, + "uvicorn_kwargs": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_start_broadcasting(params, headers): + """Test the websockets_start_broadcasting endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/start_broadcasting?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + "symbol": "ethusd", + }, + { + "name": "test_tiingo", + "auth_token": None, + "symbol": "ethusd", + }, + { + "name": "test_polygon", + "auth_token": None, + "symbol": "ethusd", + }, + ], +) +@pytest.mark.integration +def test_websockets_unsubscribe(params, headers): + """Test the websockets_unsubscribe endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/unsubscribe?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_stop_connection(params, headers): + """Test the websockets_stop_connection endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/stop_connection?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_restart_connection(params, headers): + """Test the websockets_restart_connection endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/restart_connection?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_stop_broadcasting(params, headers): + """Test the websockets_stop_broadcasting endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/stop_broadcasting?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_kill(params, headers): + """Test the websockets_kill endpoint.""" + params = {p: v for p, v in params.items() if v} + + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/websockets/kill?{query_str}" + result = requests.get(url, headers=headers, timeout=10) + assert isinstance(result, requests.Response) + assert result.status_code == 200 diff --git a/openbb_platform/extensions/websockets/integration/test_websockets_python.py b/openbb_platform/extensions/websockets/integration/test_websockets_python.py new file mode 100644 index 000000000000..5820715fa410 --- /dev/null +++ b/openbb_platform/extensions/websockets/integration/test_websockets_python.py @@ -0,0 +1,388 @@ +"""Test WebSockets Python Integration.""" + +# pylint: disable=redefined-outer-name, inconsistent-return-statements, import-outside-toplevel + +import pytest +from extensions.tests.conftest import parametrize +from openbb_core.app.model.obbject import OBBject +from openbb_websockets.models import WebSocketConnectionStatus + + +@pytest.fixture(scope="session") +def obb(pytestconfig): + """Fixture to setup obb.""" + + if pytestconfig.getoption("markexpr") != "not integration": + import openbb + + return openbb.obb + + +@parametrize( + "params", + [ + ( + { + "name": "test_fmp", + "provider": "fmp", + "symbol": "btcusd,dogeusd", + "asset_type": "crypto", + "auth_token": None, + "results_file": None, + "save_results": False, + "table_name": "records", + "limit": 10, + "sleep_time": 0.25, + "broadcast_host": "localhost", + "broadcast_port": 6666, + "start_broadcast": False, + "connect_kwargs": None, + } + ), + ( + { + "name": "test_tiingo", + "provider": "tiingo", + "symbol": "btcusd,dogeusd", + "asset_type": "crypto", + "feed": "trade_and_quote", + "auth_token": None, + "results_file": None, + "save_results": False, + "table_name": "records", + "limit": 10, + "sleep_time": 0.25, + "broadcast_host": "localhost", + "broadcast_port": 6666, + "start_broadcast": False, + "connect_kwargs": None, + } + ), + ( + { + "name": "test_polygon", + "provider": "polygon", + "symbol": "btcusd,dogeusd", + "asset_type": "crypto", + "feed": "quote", + "auth_token": None, + "results_file": None, + "save_results": False, + "table_name": "records", + "limit": 10, + "sleep_time": 0.25, + "broadcast_host": "localhost", + "broadcast_port": 6666, + "start_broadcast": False, + "connect_kwargs": None, + } + ), + ], +) +@pytest.mark.integration +def test_websockets_create_connection(params, obb): + """Test the websockets_create_connection endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.create_connection(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + assert isinstance(result.results.status, WebSocketConnectionStatus) + assert result.results.status.is_running is True + assert result.results.status.is_broadcasting is False + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_get_results(params, obb): + """Test the websockets_get_results endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.get_results(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_clear_results(params, obb): + """Test the websockets_clear_results endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.clear_results(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "symbol": "ethusd", + "auth_token": None, + }, + { + "name": "test_tiingo", + "symbol": "ethusd", + "auth_token": None, + }, + { + "name": "test_polygon", + "symbol": "ethusd", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_subscribe(params, obb): + """Test the websockets_subscribe endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.subscribe(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + "host": None, + "port": None, + "uvicorn_kwargs": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + "host": None, + "port": None, + "uvicorn_kwargs": None, + }, + { + "name": "test_polygon", + "auth_token": None, + "host": None, + "port": None, + "uvicorn_kwargs": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_start_broadcasting(params, obb): + """Test the websockets_start_broadcasting endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.start_broadcasting(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "symbol": "ethusd", + "auth_token": None, + }, + { + "name": "test_tiingo", + "symbol": "ethusd", + "auth_token": None, + }, + { + "name": "test_polygon", + "symbol": "ethusd", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_unsubscribe(params, obb): + """Test the websockets_unsubscribe endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.unsubscribe(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_get_client(params, obb): + """Test the websockets_get_client endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.get_client(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_stop_connection(params, obb): + """Test the websockets_stop_connection endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.stop_connection(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_restart_connection(params, obb): + """Test the websockets_restart_connection endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.restart_connection(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_stop_broadcasting(params, obb): + """Test the websockets_stop_broadcasting endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.stop_broadcasting(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None + + +@parametrize( + "params", + [ + { + "name": "test_fmp", + "auth_token": None, + }, + { + "name": "test_tiingo", + "auth_token": None, + }, + { + "name": "test_polygon", + "auth_token": None, + }, + ], +) +@pytest.mark.integration +def test_websockets_kill(params, obb): + """Test the websockets_kill endpoint.""" + params = {p: v for p, v in params.items() if v} + + result = obb.websockets.kill(**params) + assert result + assert isinstance(result, OBBject) + assert result.results is not None diff --git a/openbb_platform/extensions/websockets/tests/__init__.py b/openbb_platform/extensions/websockets/tests/__init__.py new file mode 100644 index 000000000000..b30092a1be6f --- /dev/null +++ b/openbb_platform/extensions/websockets/tests/__init__.py @@ -0,0 +1 @@ +"""WebSockets Extension Tests.""" From fc91aa1344add9e7b6dd9e09371f610c800e19b7 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 14 Nov 2024 23:04:17 -0800 Subject: [PATCH 33/40] some test params --- .../integration/test_websockets_api.py | 18 +++++++++--------- .../integration/test_websockets_python.py | 12 ++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openbb_platform/extensions/websockets/integration/test_websockets_api.py b/openbb_platform/extensions/websockets/integration/test_websockets_api.py index 047ff50f2efa..a01657cecb69 100644 --- a/openbb_platform/extensions/websockets/integration/test_websockets_api.py +++ b/openbb_platform/extensions/websockets/integration/test_websockets_api.py @@ -37,7 +37,7 @@ def headers(): "table_name": "records", "limit": 10, "sleep_time": 0.25, - "broadcast_host": "localhost", + "broadcast_host": "0.0.0.0", # noqa: S104 "broadcast_port": 6666, "start_broadcast": False, "connect_kwargs": None, @@ -56,7 +56,7 @@ def headers(): "table_name": "records", "limit": 10, "sleep_time": 0.25, - "broadcast_host": "localhost", + "broadcast_host": "0.0.0.0", # noqa: S104 "broadcast_port": 6666, "start_broadcast": False, "connect_kwargs": None, @@ -75,7 +75,7 @@ def headers(): "table_name": "records", "limit": 10, "sleep_time": 0.25, - "broadcast_host": "localhost", + "broadcast_host": "0.0.0.0", # noqa: S104 "broadcast_port": 6666, "start_broadcast": False, "connect_kwargs": None, @@ -196,22 +196,22 @@ def test_websockets_subscribe(params, headers): { "name": "test_fmp", "auth_token": None, - "host": None, - "port": None, + "host": "0.0.0.0", # noqa: S104 + "port": 6666, "uvicorn_kwargs": None, }, { "name": "test_tiingo", "auth_token": None, - "host": None, - "port": None, + "host": "0.0.0.0", # noqa: S104 + "port": 6667, "uvicorn_kwargs": None, }, { "name": "test_polygon", "auth_token": None, - "host": None, - "port": None, + "host": "0.0.0.0", # noqa: S104 + "port": 6668, "uvicorn_kwargs": None, }, ], diff --git a/openbb_platform/extensions/websockets/integration/test_websockets_python.py b/openbb_platform/extensions/websockets/integration/test_websockets_python.py index 5820715fa410..abd6f27740f6 100644 --- a/openbb_platform/extensions/websockets/integration/test_websockets_python.py +++ b/openbb_platform/extensions/websockets/integration/test_websockets_python.py @@ -33,7 +33,7 @@ def obb(pytestconfig): "table_name": "records", "limit": 10, "sleep_time": 0.25, - "broadcast_host": "localhost", + "broadcast_host": "0.0.0.0", # noqa: S104 "broadcast_port": 6666, "start_broadcast": False, "connect_kwargs": None, @@ -52,7 +52,7 @@ def obb(pytestconfig): "table_name": "records", "limit": 10, "sleep_time": 0.25, - "broadcast_host": "localhost", + "broadcast_host": "0.0.0.0", # noqa: S104 "broadcast_port": 6666, "start_broadcast": False, "connect_kwargs": None, @@ -71,7 +71,7 @@ def obb(pytestconfig): "table_name": "records", "limit": 10, "sleep_time": 0.25, - "broadcast_host": "localhost", + "broadcast_host": "0.0.0.0", # noqa: S104 "broadcast_port": 6666, "start_broadcast": False, "connect_kwargs": None, @@ -186,21 +186,21 @@ def test_websockets_subscribe(params, obb): { "name": "test_fmp", "auth_token": None, - "host": None, + "host": "0.0.0.0", # noqa: S104 "port": None, "uvicorn_kwargs": None, }, { "name": "test_tiingo", "auth_token": None, - "host": None, + "host": "0.0.0.0", # noqa: S104 "port": None, "uvicorn_kwargs": None, }, { "name": "test_polygon", "auth_token": None, - "host": None, + "host": "0.0.0.0", # noqa: S104 "port": None, "uvicorn_kwargs": None, }, From 3ca267fd5795459bf2b5a73b9186262a1f48942c Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Fri, 15 Nov 2024 11:36:21 -0800 Subject: [PATCH 34/40] docstring things --- .../extensions/websockets/README.md | 80 ++++++++++++++++++- .../websockets/openbb_websockets/client.py | 75 +++++++++-------- .../websockets/openbb_websockets/helpers.py | 2 +- .../openbb_websockets/websockets_router.py | 5 +- 4 files changed, 119 insertions(+), 43 deletions(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index b98a11e2aafc..ee9c120a3cec 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -454,12 +454,11 @@ PROVIDER INFO: unsubscribed to: C.XAU/USD ## Development - ### Provider Interface Providers can be added to the `create_connection` endpoint by following a slightly modified pattern. This section outlines the adaptations, but does not contain any code for actually connecting to the provider's websocket. -For details on that part, go to [websocket_client](README.md###websocket_client) section below. +For details on that part, go to [websocket_client](README.md#websocket_client) section below. Here, the Fetcher is used to start the provider client module (in a separate file) and return the client to the router, where it is intercepted and kept alive. @@ -621,6 +620,83 @@ class FmpWebSocketConnection(WebSocketConnection): """FMP WebSocket connection model.""" ``` +#### WebSocketClient + +The `WebSocketClient` is the main class responsible for bidrectional communication between the provider, broadcast, and user. It handles the child processes and can be used as a standalone class. Pasted below is the docstring for the class. + +It can be imported to use as a standalone class, and this instance is the 'client' field in `WebSocketConnection` + +```python +from openbb_websockets.client import WebSocketClient +``` + +```console + Parameters + ---------- + name : str + Name to assign the WebSocket connection. Used to identify and manage multiple instances. + module : str + The Python module for the provider websocket_client module. Runs in a separate thread. + Example: 'openbb_fmp.utils.websocket_client'. Pass additional keyword arguments by including kwargs. + symbol : Optional[str] + The symbol(s) requested to subscribe. Enter multiple symbols separated by commas without spaces. + limit : Optional[int] + The limit of records to hold in memory. Once the limit is reached, the oldest records are removed. + Default is 1000. Set to None to keep all records. + results_file : Optional[str] + Absolute path to the file for continuous writing. By default, a temporary file is created. + table_name : Optional[str] + SQL table name to store serialized data messages. By default, 'records'. + save_results : bool + Whether to persist the results after the main Python session ends. Default is False. + data_model : Optional[Data] + Pydantic data model to validate the results before storing them in the database. + Also used to deserialize the results from the database. + auth_token : Optional[str] + The authentication token to use for the WebSocket connection. Default is None. + Only used for API and Python application endpoints. + logger : Optional[logging.Logger] + The pre-configured logger instance to use for this connection. By default, a new logger is created. + kwargs : Optional[dict] + Additional keyword arguments to pass to the target provider module. Keywords and values must not contain spaces. + To pass items to 'websocket.connect()', include them in the 'kwargs' dictionary as, + {'connect_kwargs': {'key': 'value'}}. + + Properties + ---------- + symbol : str + Symbol(s) requested to subscribe. + module : str + Path to the provider connection script. + is_running : bool + Check if the provider connection process is running. + is_broadcasting : bool + Check if the broadcast server process is running. + broadcast_address : str + URI address for the results broadcast server. + results : list[Data] + All stored results from the provider's WebSocket stream. + Results are stored in a SQLite database as a serialized JSON string, this property deserializes the results. + Clear the results by deleting the property. e.g., del client.results + + Methods + ------- + connect + Connect to the provider WebSocket stream. + disconnect + Disconnect from the provider WebSocket. + subscribe + Subscribe to a new symbol or list of symbols. + unsubscribe + Unsubscribe from a symbol or list of symbols. + start_broadcasting + Start the broadcast server to stream results over a network connection. + stop_broadcasting + Stop the broadcast server and disconnect all listening clients. + send_message + Send a message to the WebSocket process. Messages can be sent to "provider" or "broadcast" targets. +``` + #### WebSocketFetcher This is where things diverge slightly. Instead of returning `FmpWebSocketData`, it gets passed to the client connection for validating records as they are received. What gets returned by the Fetcher is the `WebSocketConnection`. diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 72a720b3ce5d..1aa7178ce502 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -3,7 +3,7 @@ # pylint: disable=too-many-statements # flake8: noqa: PLR0915 import logging -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Union if TYPE_CHECKING: from openbb_core.provider.abstract.data import Data @@ -17,13 +17,13 @@ class WebSocketClient: name : str Name to assign the WebSocket connection. Used to identify and manage multiple instances. module : str - The Python module for the provider server connection. Runs in a separate thread. - Example: 'openbb_fmp.websockets.server'. Pass additional keyword arguments by including kwargs. + The Python module for the provider websocket_client module. Runs in a separate thread. + Example: 'openbb_fmp.utils.websocket_client'. Pass additional keyword arguments by including kwargs. symbol : Optional[str] The symbol(s) requested to subscribe. Enter multiple symbols separated by commas without spaces. limit : Optional[int] The limit of records to hold in memory. Once the limit is reached, the oldest records are removed. - Default is 300. Set to None to keep all records. + Default is 1000. Set to None to keep all records. results_file : Optional[str] Absolute path to the file for continuous writing. By default, a temporary file is created. table_name : Optional[str] @@ -55,12 +55,10 @@ class WebSocketClient: Check if the broadcast server process is running. broadcast_address : str URI address for the results broadcast server. - results : list - All stored results from the provider's WebSocket stream. The results are stored in a SQLite database. - Set the 'limit' property to cap the number of stored records. + results : list[Data] + All stored results from the provider's WebSocket stream. + Results are stored in a SQLite database as a serialized JSON string, this property deserializes the results. Clear the results by deleting the property. e.g., del client.results - transformed_results : list - Deserialize the records from the results file using the provided data model, if available. Methods ------- @@ -75,9 +73,9 @@ class WebSocketClient: start_broadcasting Start the broadcast server to stream results over a network connection. stop_broadcasting - Stop the broadcast server and disconnect all reading clients. + Stop the broadcast server and disconnect all listening clients. send_message - Send a message to the WebSocket process. + Send a message to the WebSocket process. Messages can be sent to "provider" or "broadcast" targets. """ def __init__( # noqa: PLR0913 @@ -85,7 +83,7 @@ def __init__( # noqa: PLR0913 name: str, module: str, symbol: Optional[str] = None, - limit: Optional[int] = 300, + limit: Optional[int] = 1000, results_file: Optional[str] = None, table_name: Optional[str] = None, save_results: bool = False, @@ -103,6 +101,7 @@ def __init__( # noqa: PLR0913 from aiosqlite import DatabaseError from queue import Queue from pathlib import Path + from openbb_core.app.model.abstract.error import OpenBBError from openbb_websockets.helpers import get_logger self.name = name @@ -119,20 +118,20 @@ def __init__( # noqa: PLR0913 else None ) - self._process = None - self._psutil_process = None - self._thread = None - self._log_thread = None - self._provider_message_queue = Queue() - self._stop_log_thread_event = threading.Event() - self._stop_broadcasting_event = threading.Event() - self._broadcast_address = None - self._broadcast_process = None - self._psutil_broadcast_process = None - self._broadcast_thread = None - self._broadcast_log_thread = None - self._broadcast_message_queue = Queue() - self._exception = None + self._process: Any = None + self._psutil_process: Any = None + self._thread: Any = None + self._log_thread: Any = None + self._provider_message_queue: Queue = Queue() + self._stop_log_thread_event: threading.Event = threading.Event() + self._stop_broadcasting_event: threading.Event = threading.Event() + self._broadcast_address: Any = None + self._broadcast_process: Any = None + self._psutil_broadcast_process: Any = None + self._broadcast_thread: Any = None + self._broadcast_log_thread: Any = None + self._broadcast_message_queue: Queue = Queue() + self._exception: Any = None if not results_file: with tempfile.NamedTemporaryFile(delete=False) as temp_file: @@ -150,7 +149,9 @@ def __init__( # noqa: PLR0913 try: self._setup_database() except DatabaseError as e: - self.logger.error("Error setting up the SQLite database and table: %s", e) + msg = f"Unexpected error setting up the SQLite database and table -> {e.__class___.__name__}: {e.__str__()}" + self.logger.error(msg) + self._exception = OpenBBError(msg) def _atexit(self) -> None: """Clean up the WebSocket client processes at exit.""" @@ -164,8 +165,8 @@ def _atexit(self) -> None: if self.is_broadcasting: self.stop_broadcasting() if self.save_results: - self.logger.info("Websocket results saved to, %s\n", self.results_file) - if os.path.exists(self.results_file): + self.logger.info("Websocket results saved to, %s\n", str(self.results_path)) + if os.path.exists(self.results_file) and not self.save_results: os.remove(self.results_file) def _setup_database(self) -> None: @@ -476,7 +477,7 @@ def is_broadcasting(self) -> bool: return False @property - def results(self) -> Union[list[dict], None]: + def results(self) -> Union[list[dict], list["Data"], None]: """Retrieve the deserialized results from the results file.""" # pylint: disable=import-outside-toplevel import json # noqa @@ -489,7 +490,12 @@ def results(self) -> Union[list[dict], None]: cursor = conn.execute(f"SELECT * FROM {self.table_name}") # noqa for row in cursor: index, message = row - output.append(json.loads(json.loads(message))) + if self.data_model: + output.append( + self.data_model.model_validate_json(json.loads(message)) + ) + else: + output.append(json.loads(json.loads(message))) if output: return output @@ -665,13 +671,6 @@ def stop_broadcasting(self): self._stop_broadcasting_event.clear() return - @property - def transformed_results(self) -> list["Data"]: - """Model validated records from the results file.""" - if not self.data_model: - raise NotImplementedError("No model provided to transform the results.") - return [self.data_model.model_validate(d) for d in self.results] - def __repr__(self): """Return the WebSocketClient representation.""" return ( diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index d8cedfcba97a..4dc8b1444046 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -41,7 +41,7 @@ def get_logger(name, level=logging.INFO): def handle_validation_error(logger: logging.Logger, error: ValidationError): """Log and raise a Pydantic ValidationError from a provider connection.""" - err = f"{error.__class__.__name__} -> {error.title}: {str(error.json())}" + err = f"{error.__class__.__name__} -> {error.title}: {error.json()}" logger.error(err) raise error from error diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index 5445e0e1b6a4..cd2a9c280344 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -18,6 +18,7 @@ from openbb_core.app.query import Query from openbb_core.app.router import Router from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError +from pydantic import ValidationError from openbb_websockets.helpers import ( StdOutSink, @@ -101,9 +102,9 @@ async def get_results(name: str, auth_token: Optional[str] = None) -> OBBject: if not client.results: raise EmptyDataError(f"No results recorded for client {name}.") try: - return OBBject(results=client.transformed_results) - except NotImplementedError: return OBBject(results=client.results) + except ValidationError as e: + raise OpenBBError(e) from e @router.command( From ed2e6732512c21a7bd2d2fe424f821af99d110e0 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:12:47 -0800 Subject: [PATCH 35/40] store key and auth_token as encrypted values --- .../websockets/openbb_websockets/broadcast.py | 22 ++++++- .../websockets/openbb_websockets/client.py | 60 ++++++++++++++++--- .../websockets/openbb_websockets/helpers.py | 32 +++++++++- 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py index cabca40c8829..3612a1f7dafb 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/broadcast.py @@ -65,7 +65,7 @@ async def websocket_endpoint( # noqa: PLR0915 if ( broadcast_server.auth_token is not None - and auth_token != broadcast_server.auth_token + and auth_token != broadcast_server._decrypt_value(broadcast_server.auth_token) ): await websocket.accept() await websocket.send_text( @@ -131,15 +131,33 @@ def __init__( sleep_time: float = 0.25, auth_token: Optional[str] = None, ): + # pylint: disable=import-outside-toplevel + import os self.results_file = results_file self.table_name = table_name self.logger = get_logger("openbb.websocket.broadcast_server") self.sleep_time = sleep_time - self.auth_token = auth_token self._app = app + self._key = os.urandom(32) + self._iv = os.urandom(16) + self.auth_token = self._encrypt_value(auth_token) if auth_token else None self.websocket = None + def _encrypt_value(self, value: str) -> str: + """Encrypt the value for storage.""" + # pylint: disable=import-outside-toplevel + from openbb_websockets.helpers import encrypt_value + + return encrypt_value(self._key, self._iv, value) + + def _decrypt_value(self, value: str) -> str: + """Decrypt the value for use.""" + # pylint: disable=import-outside-toplevel + from openbb_websockets.helpers import decrypt_value + + return decrypt_value(self._key, self._iv, value) + async def stream_results(self): # noqa: PLR0915 """Continuously read the database and send new messages as JSON via WebSocket.""" import sqlite3 # noqa diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 1aa7178ce502..1e412fbdb35c 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -2,6 +2,7 @@ # pylint: disable=too-many-statements # flake8: noqa: PLR0915 + import logging from typing import TYPE_CHECKING, Any, Literal, Optional, Union @@ -96,6 +97,7 @@ def __init__( # noqa: PLR0913 # pylint: disable=import-outside-toplevel import asyncio # noqa import atexit + import os import tempfile import threading from aiosqlite import DatabaseError @@ -110,13 +112,20 @@ def __init__( # noqa: PLR0913 self.table_name = table_name if table_name else "records" self._limit = limit self.data_model = data_model - self._auth_token = auth_token self._symbol = symbol - self._kwargs = ( - [f"{k}={str(v).strip().replace(' ', '_')}" for k, v in kwargs.items()] - if kwargs - else None - ) + self._key = os.urandom(32) + self._iv = os.urandom(16) + self._auth_token = self._encrypt_value(auth_token) if auth_token else None + # strings in kwargs are encrypted before storing in the class but unencrypted when passed to the provider module. + if kwargs: + for k, v in kwargs.items(): + if isinstance(v, str): + encrypted_value = self._encrypt_value(v) + kwargs[k] = encrypted_value + else: + kwargs[k] = v + + self._kwargs = kwargs if kwargs else {} self._process: Any = None self._psutil_process: Any = None @@ -153,6 +162,20 @@ def __init__( # noqa: PLR0913 self.logger.error(msg) self._exception = OpenBBError(msg) + def _encrypt_value(self, value): + """Encrypt a value before storing.""" + # pylint: disable=import-outside-toplevel + from openbb_websockets.helpers import encrypt_value + + return encrypt_value(self._key, self._iv, value) + + def _decrypt_value(self, encrypted_value): + """Decrypt the value for use.""" + # pylint: disable=import-outside-toplevel + from openbb_websockets.helpers import decrypt_value + + return decrypt_value(self._key, self._iv, encrypted_value) + def _atexit(self) -> None: """Clean up the WebSocket client processes at exit.""" # pylint: disable=import-outside-toplevel @@ -349,8 +372,23 @@ def connect(self) -> None: if self.limit: command.extend([f"limit={self.limit}"]) - if self._kwargs: - for kwarg in self._kwargs: + kwargs = self._kwargs.copy() + + if kwargs: + for k, v in kwargs.items(): + if isinstance(v, str): + unencrypted_value = self._decrypt_value(v) + kwargs[k] = unencrypted_value + else: + kwargs[k] = v + + _kwargs = ( + [f"{k}={str(v).strip().replace(' ', '_')}" for k, v in kwargs.items()] + if kwargs + else None + ) + + for kwarg in _kwargs: if kwarg not in command: command.extend([kwarg]) @@ -566,6 +604,10 @@ def broadcast_address(self) -> Union[str, None]: else None ) + def _get_auth_token(self): + """Get the authentication token.""" + return self._decrypt_value(self._auth_token) if self._auth_token else None + def start_broadcasting( self, host: str = "127.0.0.1", @@ -605,7 +647,7 @@ def start_broadcasting( f"port={open_port}", f"results_file={self.results_file}", f"table_name={self.table_name}", - f"auth_token={self._auth_token}", + f"auth_token={self._get_auth_token()}", ] if kwargs: for kwarg in kwargs: diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index 4dc8b1444046..d86a3523a9fb 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -73,6 +73,36 @@ async def get_status(name: Optional[str] = None, client: Optional[Any] = None) - return status +def encrypt_value(key, iv, value): + """Encrypt a value before storing.""" + # pylint: disable=import-outside-toplevel + import base64 # noqa + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + backend = default_backend() + cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=backend) + encryptor = cipher.encryptor() + encrypted_value = encryptor.update(value.encode()) + encryptor.finalize() + return base64.b64encode(encrypted_value).decode() + + +def decrypt_value(key, iv, encrypted_value): + """Decrypt the value for use.""" + # pylint: disable=import-outside-toplevel + import base64 # noqa + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + backend = default_backend() + cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=backend) + decryptor = cipher.decryptor() + decrypted_value = ( + decryptor.update(base64.b64decode(encrypted_value)) + decryptor.finalize() + ) + return decrypted_value.decode() + + async def check_auth(name: str, auth_token: Optional[str] = None) -> bool: """Check the auth token.""" if name not in connected_clients: @@ -82,7 +112,7 @@ async def check_auth(name: str, auth_token: Optional[str] = None) -> bool: return True if auth_token is None: raise UnauthorizedError(f"Client authorization token is required for {name}.") - if auth_token != client._auth_token: + if auth_token != client._get_auth_token(): raise UnauthorizedError(f"Invalid client authorization token for {name}.") return True From 8337346cf5f9847e1ed8fd75d716f3dd74a2b876 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:25:54 -0800 Subject: [PATCH 36/40] cleanup --- .../extensions/websockets/openbb_websockets/client.py | 6 +----- .../extensions/websockets/openbb_websockets/helpers.py | 9 +++++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 1e412fbdb35c..671eb7fe545e 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -604,10 +604,6 @@ def broadcast_address(self) -> Union[str, None]: else None ) - def _get_auth_token(self): - """Get the authentication token.""" - return self._decrypt_value(self._auth_token) if self._auth_token else None - def start_broadcasting( self, host: str = "127.0.0.1", @@ -647,7 +643,7 @@ def start_broadcasting( f"port={open_port}", f"results_file={self.results_file}", f"table_name={self.table_name}", - f"auth_token={self._get_auth_token()}", + f"auth_token={self._decrypt_value(self._auth_token)}", ] if kwargs: for kwarg in kwargs: diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index d86a3523a9fb..78a5b2b8b396 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -2,7 +2,6 @@ import logging import re -import sys from typing import Any, Optional from openbb_core.app.model.abstract.error import OpenBBError @@ -112,7 +111,7 @@ async def check_auth(name: str, auth_token: Optional[str] = None) -> bool: return True if auth_token is None: raise UnauthorizedError(f"Client authorization token is required for {name}.") - if auth_token != client._get_auth_token(): + if auth_token != client._decrypt_value(client._auth_token): raise UnauthorizedError(f"Invalid client authorization token for {name}.") return True @@ -225,6 +224,9 @@ class StdOutSink: def write(self, message): """Write to stdout.""" + # pylint: disable=import-outside-toplevel + import sys + cleaned_message = AUTH_TOKEN_FILTER.sub(r"\1********", message) if cleaned_message != message: cleaned_message = f"{cleaned_message}\n" @@ -232,6 +234,9 @@ def write(self, message): def flush(self): """Flush stdout.""" + # pylint: disable=import-outside-toplevel + import sys + sys.__stdout__.flush() From 2934cbe9f88a121237a02d4b8a24cdd1b897e2b6 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 18 Nov 2024 23:26:03 -0800 Subject: [PATCH 37/40] more cleanup --- .../extensions/websockets/openbb_websockets/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 671eb7fe545e..24055166c0bb 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -158,7 +158,7 @@ def __init__( # noqa: PLR0913 try: self._setup_database() except DatabaseError as e: - msg = f"Unexpected error setting up the SQLite database and table -> {e.__class___.__name__}: {e.__str__()}" + msg = f"Unexpected error setting up the SQLite database and table -> {e.__class___.__name__}: {e}" self.logger.error(msg) self._exception = OpenBBError(msg) @@ -765,7 +765,9 @@ def send_message( else: client.logger.error("Broadcast process is not running.") except Exception as e: - msg = f"Error sending message to WebSocket process: {e.__class__.__name__} -> {e.__str__()}" + msg = ( + f"Error sending message to WebSocket process: {e.__class__.__name__} -> {e}" + ) client.logger.error(msg) From 7f3b7bc84c876d141ec0a8b197ab5dbf57f0c67c Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Tue, 19 Nov 2024 18:31:41 -0800 Subject: [PATCH 38/40] small update --- .../extensions/websockets/README.md | 34 ++++++++++--------- .../websockets/openbb_websockets/models.py | 6 ++-- .../openbb_fmp/models/websocket_connection.py | 8 ++--- .../fmp/openbb_fmp/utils/websocket_client.py | 16 +++++---- .../providers/fmp/tests/test_fmp_fetchers.py | 17 ++++++++++ .../models/websocket_connection.py | 8 ++--- .../openbb_polygon/utils/websocket_client.py | 16 ++++----- .../models/websocket_connection.py | 8 ++--- .../openbb_tiingo/utils/websocket_client.py | 12 +++---- 9 files changed, 70 insertions(+), 55 deletions(-) diff --git a/openbb_platform/extensions/websockets/README.md b/openbb_platform/extensions/websockets/README.md index ee9c120a3cec..128d9010734e 100644 --- a/openbb_platform/extensions/websockets/README.md +++ b/openbb_platform/extensions/websockets/README.md @@ -33,7 +33,7 @@ obb.websockets ``` > Except for, `get_results`, functions do not return the data or stream. Outputs will be a WebSocketConnectionStatus instance, or a string message. -> All functions, except `create_connection`, assume that a connection has already been establiehd and are referenced by parameters: +> All functions, except `create_connection`, assume that a connection has already been established and are referenced by parameters: > > |Parameter|Type | Required| Description | > |:-------|:-----|:--------:|------------:| @@ -52,7 +52,7 @@ All other endpoints require this to be used first. It is the only function mappi |:-------|:-----|:--------:|------------:| |provider |String |Yes |Name of the provider - i.e, `"polygon"`, `"fmp"`, `"tiingo"` | |name |String |Yes |Name to assign the connection. This is the 'name' parameter in the other endpoints.| -|auth_token |String |No |When supplied, the same token must be passed for all future interactions with the connection, or to read from the broadcast server. | +|auth_token |String |No |When supplied, the same token must be passed as a URL parameter to the broadcast server, and to interact with the client from the API. | |results_file |String |No |Absolute path to the file for continuous writing. Temp file is created by default. Unless 'save_results' is True, discarded on exit. | |save_results |Boolean |No |Whether to persist the file after the session ends, default is `False` | |table_name |String |No |Name of the SQL table to write the results to, consisting of an auto-increment ID and a serialized JSON string of the data. Default is `"records"`| @@ -107,7 +107,7 @@ conn.results.model_dump() ``` ```sh -{'name': 'crypto_tiingo', +{'status': {'name': 'crypto_tiingo', 'auth_required': False, 'subscribed_symbols': '*', 'is_running': True, @@ -117,9 +117,11 @@ conn.results.model_dump() 'broadcast_pid': 5813, 'results_file': '/var/folders/kc/j2lm7bkd5dsfqqnvz259gm6c0000gn/T/tmpwb4jslbg', 'table_name': 'records', - 'save_results': False} + 'save_results': False}} ``` +> From the Python interface, the client is also included in the results. Access it from `results.client` + All of the currently captured data can be dumped with the `get_results` endpoint. The return will be the typical data response object. ```python @@ -450,8 +452,6 @@ PROVIDER INFO: unsubscribed to: C.XAU/USD 'save_results': False} ``` - - ## Development ### Provider Interface @@ -609,7 +609,7 @@ class FmpWebSocketData(WebSocketData): This model is what we return from the `FmpWebSocketFetcher`. The provider will inherit two fields, only the `client` needs to be included on output. No additional fields should be defined. - client - - Instance of WebSocketClient, not returned to from the Router. + - Instance of WebSocketClient, not returned to the API. - status - Leave empty, the Router fills this and returns it. @@ -701,6 +701,8 @@ from openbb_websockets.client import WebSocketClient This is where things diverge slightly. Instead of returning `FmpWebSocketData`, it gets passed to the client connection for validating records as they are received. What gets returned by the Fetcher is the `WebSocketConnection`. +> + ```python class FmpWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnection]): """FMP WebSocket model.""" @@ -715,7 +717,7 @@ class FmpWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnectio query: FmpWebSocketQueryParams, credentials: Optional[dict[str, str]], **kwargs: Any, - ) -> WebSocketClient: + ) -> dict: """Extract data from the WebSocket.""" # pylint: disable=import-outside-toplevel import asyncio @@ -766,19 +768,19 @@ class FmpWebSocketFetcher(Fetcher[FmpWebSocketQueryParams, FmpWebSocketConnectio raise e from e # Check if the process is still running before returning. if client.is_running: - return client + return {"client": client} raise OpenBBError("Failed to connect to the WebSocket.") @staticmethod def transform_data( - data: WebSocketClient, + data: dict, query: FmpWebSocketQueryParams, **kwargs: Any, ) -> FmpWebSocketConnection: """Return the client as an instance of Data.""" # All we need to do here is return our client wrapped in the WebSocketConnection class. - return FmpWebSocketConnection(client=data) + return FmpWebSocketConnection(client=data["client"]) ``` #### Map To Router @@ -846,7 +848,7 @@ logger = get_logger("openbb.websocket.fmp") # A UUID gets attached to the name s #### `MessageQueue` -This is an async Queue with an input for the message handler. Create a second instance if a separate queue is required for the subscibe events. +This is an async Queue with an input for the message handler. Create a second instance if a separate queue is required for the subscribe events. Define your async message handler function, and create a task to run in the main event loop. @@ -875,7 +877,7 @@ message = await queue.dequeue() Before submitting the record to `write_to_db`, validate and transform the data with the WebSocketData that was created and imported. Use this function right before transmission, a failure will trigger a termination signal from the main application. ```python -# code above confirms that the message being processed is a data message and not an info message or error. +# code above confirms that the message being processed is a data message and not an info or error message. try: result = FmpWebSocketData.model_validate(message).model_dump_json( @@ -920,8 +922,8 @@ if __name__ == "__main__": for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, handle_termination_signal, logger) - # asyncio.run_coroutine_threadsafe(some_connect_and_stream_function, loop) - # loop.run_forever() + asyncio.run_coroutine_threadsafe(some_connect_and_stream_function, loop) + loop.run_forever() ... ``` @@ -930,7 +932,7 @@ if __name__ == "__main__": The missing pieces that get created locally include: - Read `stdin` function for receiving subscribe/unsubscribe events while the connection is running. - - Messages to handle will always have the same format: `'{"event": "subscribe", "symbol": ticker}'` + - Messages to handle will always have the same format: `'{"event": "(un)subscribe", "symbol": ticker}'` - Converting for the symbology used by the provider needs to happen here. - Implementation depends on the requirements of the provider - i.e, how to structure send events. - Create the task before the initial `websockets.connect` block. diff --git a/openbb_platform/extensions/websockets/openbb_websockets/models.py b/openbb_platform/extensions/websockets/openbb_websockets/models.py index 8e53e72233ed..ae90ff2352e9 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/models.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/models.py @@ -172,8 +172,8 @@ def _validate_client(cls, v): @model_validator(mode="before") @classmethod - def _validate_inputs(cls, vaules): + def _validate_inputs(cls, values): """Validate the status.""" - if not vaules.get("status") and not vaules.get("client"): + if not values.get("status") and not values.get("client"): raise ValueError("Cannot initialize empty.") - return vaules + return values diff --git a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py index b157e572548a..c9fd7a5d7654 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py +++ b/openbb_platform/providers/fmp/openbb_fmp/models/websocket_connection.py @@ -140,7 +140,7 @@ async def aextract_data( query: FmpWebSocketQueryParams, credentials: Optional[dict[str, str]], **kwargs: Any, - ) -> WebSocketClient: + ) -> dict: """Extract data from the WebSocket.""" # pylint: disable=import-outside-toplevel import asyncio @@ -183,15 +183,15 @@ async def aextract_data( raise e from e if client.is_running: - return client + return {"client": client} raise OpenBBError("Failed to connect to the WebSocket.") @staticmethod def transform_data( - data: WebSocketClient, + data: dict, query: FmpWebSocketQueryParams, **kwargs: Any, ) -> FmpWebSocketConnection: """Return the client as an instance of Data.""" - return FmpWebSocketConnection(client=data) + return FmpWebSocketConnection(client=data["client"]) diff --git a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py index b54890874bc8..75bfb8915da6 100644 --- a/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py +++ b/openbb_platform/providers/fmp/openbb_fmp/utils/websocket_client.py @@ -48,7 +48,7 @@ async def login(websocket, api_key): msg = message.get("message") logger.info("PROVIDER INFO: %s", msg) except Exception as e: # pylint: disable=broad-except - msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e.__str__()}" + msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e}" logger.error(msg) sys.exit(1) @@ -66,7 +66,7 @@ async def subscribe(websocket, symbol, event): await websocket.send(json.dumps(subscribe_event)) await asyncio.sleep(1) except Exception as e: # pylint: disable=broad-except - msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e.__str__()}" + msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e}" logger.error(msg) @@ -105,6 +105,10 @@ async def process_message(message, results_path, table_name, limit): if "you are not authorized" in message.get("message", "").lower(): msg = f"UnauthorizedError -> FMP Message: {message['message']}" logger.error(msg) + elif "Connected from another location" in message.get("message", ""): + msg = f"UnauthorizedError -> FMP Message: {message.get('message')}" + logger.info(msg) + sys.exit(0) else: msg = f"PROVIDER INFO: {message.get('message')}" logger.info(msg) @@ -156,9 +160,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim await asyncio.shield(queue.enqueue(json.loads(message))) except websockets.ConnectionClosed as e: - msg = ( - f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" - ) + msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e}" logger.info(msg) # Attempt to reopen the connection logger.info("PROVIDER INFO: Attempting to reconnect after five seconds.") @@ -170,7 +172,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim sys.exit(1) except Exception as e: - msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e.__str__()}" + msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e}" logger.error(msg) sys.exit(1) @@ -207,7 +209,7 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim logger.error("PROVIDER ERROR: WebSocket connection closed") except Exception as e: # pylint: disable=broad-except - msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e.__str__()}" + msg = f"PROVIDER ERROR: {e.__class__.__name__}: {e}" logger.error(msg) finally: diff --git a/openbb_platform/providers/fmp/tests/test_fmp_fetchers.py b/openbb_platform/providers/fmp/tests/test_fmp_fetchers.py index 60074329bc6f..42d48f6e8f3d 100644 --- a/openbb_platform/providers/fmp/tests/test_fmp_fetchers.py +++ b/openbb_platform/providers/fmp/tests/test_fmp_fetchers.py @@ -70,6 +70,7 @@ from openbb_fmp.models.risk_premium import FMPRiskPremiumFetcher from openbb_fmp.models.share_statistics import FMPShareStatisticsFetcher from openbb_fmp.models.treasury_rates import FMPTreasuryRatesFetcher +from openbb_fmp.models.websocket_connection import FmpWebSocketFetcher from openbb_fmp.models.world_news import FMPWorldNewsFetcher from openbb_fmp.models.yield_curve import FMPYieldCurveFetcher @@ -777,3 +778,19 @@ def test_fmp_historical_market_cap_fetcher(credentials=test_credentials): fetcher = FmpHistoricalMarketCapFetcher() result = fetcher.test(params, credentials) assert result is None + + +@pytest.mark.record_screen +def test_fmp_websocket_fetcher(credentials=test_credentials): + """Test FMP Websocket fetcher.""" + import asyncio + + params = {"symbol": "btcusd", "name": "test", "limit": 10, "asset_type": "crypto"} + + try: + fetcher = FmpWebSocketFetcher() + result = fetcher.test(params, credentials) + response = asyncio.run(fetcher.fetch_data(params, credentials)) + assert result is None + finally: + response.client.disconnect() diff --git a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py index 0960bb4850db..48d3f708ffdd 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py +++ b/openbb_platform/providers/polygon/openbb_polygon/models/websocket_connection.py @@ -1219,7 +1219,7 @@ def extract_data( query: PolygonWebSocketQueryParams, credentials: Optional[dict[str, str]], **kwargs: Any, - ) -> WebSocketClient: + ) -> dict: """Extract data from the WebSocket.""" api_key = credentials.get("polygon_api_key") if credentials else "" url = URL_MAP[query.asset_type] @@ -1261,15 +1261,15 @@ def extract_data( raise client._exception from client._exception if client.is_running: - return client + return {"client": client} raise OpenBBError("Failed to connect to the WebSocket.") @staticmethod def transform_data( - data: WebSocketClient, + data: dict, query: PolygonWebSocketQueryParams, **kwargs: Any, ) -> PolygonWebSocketConnection: """Return the client as an instance of Data.""" - return PolygonWebSocketConnection(client=data) + return PolygonWebSocketConnection(client=data["client"]) diff --git a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py index e22421abe2bd..cdb706542ee8 100644 --- a/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py +++ b/openbb_platform/providers/polygon/openbb_polygon/utils/websocket_client.py @@ -233,27 +233,23 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim except websockets.InvalidStatusCode as e: if e.status_code == 404: - msg = f"PROVIDER ERROR: {e.__str__()}" + msg = f"PROVIDER ERROR: {e}" logger.error(msg) sys.exit(1) else: raise except websockets.InvalidURI as e: - msg = f"PROVIDER ERROR: {e.__str__()}" + msg = f"PROVIDER ERROR: {e}" logger.error(msg) sys.exit(1) except websockets.ConnectionClosedOK as e: - msg = ( - f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" - ) + msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e}" logger.info(msg) sys.exit(0) except websockets.ConnectionClosed as e: - msg = ( - f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" - ) + msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e}" logger.info(msg) # Attempt to reopen the connection logger.info("PROVIDER INFO: Attempting to reconnect after five seconds.") @@ -261,12 +257,12 @@ async def connect_and_stream(url, symbol, api_key, results_path, table_name, lim await connect_and_stream(url, symbol, api_key, results_path, table_name, limit) except websockets.WebSocketException as e: - msg = f"PROVIDER ERROR: WebSocketException -> {e.__str__()}" + msg = f"PROVIDER ERROR: WebSocketException -> {e}" logger.error(msg) sys.exit(1) except Exception as e: - msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e.__str__()}" + msg = f"PROVIDER ERROR: Unexpected error -> {e.__class__.__name__}: {e}" logger.error(msg) sys.exit(1) diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py index 3b083645952d..ffeb3a3781d6 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/models/websocket_connection.py @@ -233,7 +233,7 @@ async def aextract_data( query: TiingoWebSocketQueryParams, credentials: Optional[dict[str, str]], **kwargs: Any, - ) -> WebSocketClient: + ) -> dict: """Initiailze the WebSocketClient and connect.""" # pylint: disable=import-outside-toplevel from asyncio import sleep @@ -291,16 +291,16 @@ async def aextract_data( raise OpenBBError(exc) if client.is_running: - return client + return {"client": client} client.disconnect() raise OpenBBError("Failed to connect to the WebSocket.") @staticmethod def transform_data( - data: WebSocketClient, + data: dict, query: TiingoWebSocketQueryParams, **kwargs: Any, ) -> TiingoWebSocketConnection: """Return the client as an instance of Data.""" - return TiingoWebSocketConnection(client=data) + return TiingoWebSocketConnection(client=data["client"]) diff --git a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py index 50ca68c09c19..25243c9dc55a 100644 --- a/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py +++ b/openbb_platform/providers/tiingo/openbb_tiingo/utils/websocket_client.py @@ -236,9 +236,7 @@ async def connect_and_stream( sys.exit(1) except websockets.ConnectionClosed as e: - msg = ( - f"PROVIDER INFO: The WebSocket connection was closed -> {e.__str__()}" - ) + msg = f"PROVIDER INFO: The WebSocket connection was closed -> {e}" logger.info(msg) # Attempt to reopen the connection logger.info("PROVIDER INFO: Attempting to reconnect after five seconds...") @@ -248,11 +246,11 @@ async def connect_and_stream( ) except websockets.WebSocketException as e: - logger.error(str(e)) - sys.exit(1) + logger.info(str(e)) + sys.exit(0) except Exception as e: - msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" + msg = f"Unexpected error -> {e.__class__.__name__}: {e}" logger.error(msg) sys.exit(1) @@ -286,7 +284,7 @@ async def connect_and_stream( loop.run_forever() except Exception as e: # pylint: disable=broad-except - msg = f"Unexpected error -> {e.__class__.__name__}: {e.__str__()}" + msg = f"Unexpected error -> {e.__class__.__name__}: {e}" logger.error(msg) finally: From 5d8d9b47d1c64ec59454d12ae7047954b70316c2 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:04:08 -0800 Subject: [PATCH 39/40] don't decrypt auth_token when value is None --- .../websockets/openbb_websockets/client.py | 2 +- .../openbb_websockets/websockets_router.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/client.py b/openbb_platform/extensions/websockets/openbb_websockets/client.py index 24055166c0bb..a243fd779a81 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/client.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/client.py @@ -643,7 +643,7 @@ def start_broadcasting( f"port={open_port}", f"results_file={self.results_file}", f"table_name={self.table_name}", - f"auth_token={self._decrypt_value(self._auth_token)}", + f"auth_token={self._decrypt_value(self._auth_token) if self._auth_token else None}", ] if kwargs: for kwarg in kwargs: diff --git a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py index cd2a9c280344..f75af9b37f3e 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/websockets_router.py @@ -67,8 +67,16 @@ async def create_connection( raise OpenBBError("Client failed to connect.") if hasattr(extra_params, "start_broadcast") and extra_params.start_broadcast: - client.start_broadcasting() - await asyncio.sleep(1) + try: + client.start_broadcasting() + await asyncio.sleep(1) + if client._exception is not None: + exc = getattr(client, "_exception", None) + client._exception = None + raise OpenBBError(exc) + except Exception as e: # pylint: disable=broad-except + client._atexit() + raise e from e client_name = client.name connected_clients[client_name] = client From a4336725c653769d3a0a5551458a72d6b0ec0db8 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:09:07 -0800 Subject: [PATCH 40/40] handle url in parse_kwargs --- .../extensions/websockets/openbb_websockets/helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py index 78a5b2b8b396..ae7f30836f1a 100644 --- a/openbb_platform/extensions/websockets/openbb_websockets/helpers.py +++ b/openbb_platform/extensions/websockets/openbb_websockets/helpers.py @@ -134,8 +134,12 @@ def parse_kwargs(): import sys args = sys.argv[1:].copy() + sys.stdout.write(f"ARGS: {args}\n") _kwargs: dict = {} for i, arg in enumerate(args): + if arg.startswith("url"): + _kwargs["url"] = arg[4:] + continue if "=" in arg: key, value = arg.split("=")