diff --git a/.vscode/settings.json b/.vscode/settings.json index 009d148db5..056ea4f4bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,5 @@ { - "spellright.language": [ - "en_US" - ], + "spellright.language": ["en_US"], "spellright.documentTypes": [ "markdown", "latex", @@ -14,10 +12,10 @@ "python.linting.flake8Enabled": true, "workbench.editor.wrapTabs": true, "python.formatting.provider": "black", - "python.testing.pytestArgs": [ - "flexmeasures" - ], + "python.testing.pytestArgs": ["flexmeasures"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "python.analysis.autoImportCompletions": true + "python.analysis.autoImportCompletions": true, + "stm32-for-vscode.openOCDPath": false, + "stm32-for-vscode.armToolchainPath": false } diff --git a/flexmeasures/api/common/routes.py b/flexmeasures/api/common/routes.py index 148ed7984b..5e36c7821b 100644 --- a/flexmeasures/api/common/routes.py +++ b/flexmeasures/api/common/routes.py @@ -1,4 +1,9 @@ -from flask_security import auth_token_required +from collections import deque +import os + +from flask import current_app, request, Response +from flask_security import auth_token_required, login_required +from werkzeug.exceptions import NotFound, abort from flexmeasures.auth.decorators import roles_required from flexmeasures.api.common import flexmeasures_api as flexmeasures_api_ops @@ -20,3 +25,20 @@ def get_task_run(): @roles_required("task-runner") def post_task_run(): return ops_impl.post_task_run() + + +@flexmeasures_api_ops.route("/logs") +@login_required +@roles_required("debugger") +def show_logs(): + """Show server logs for debugging.""" + if current_app.config.get("LOGGING_LEVEL") != "DEBUG": + raise NotFound + + log_file = "flexmeasures.log" + n = int(request.args.get("tail", 200)) + if not os.path.exists(log_file): + abort(404, "Log file not found") + with open(log_file, "r") as f: + last_n_lines = deque(f, maxlen=n) + return Response("".join(last_n_lines), mimetype="text/plain") diff --git a/flexmeasures/app.py b/flexmeasures/app.py index 538d503eed..5df9fc7e4a 100644 --- a/flexmeasures/app.py +++ b/flexmeasures/app.py @@ -20,6 +20,7 @@ from rq import Queue from flexmeasures.data.services.job_cache import JobCache +from flexmeasures.ws import sock def create( # noqa C901 @@ -56,6 +57,7 @@ def create( # noqa C901 cfg_location = find_flexmeasures_cfg() # Find flexmeasures.cfg location # Create app app = Flask("flexmeasures") + sock.init_app(app) if env is not None: # overwrite app.config["FLEXMEASURES_ENV"] = env diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 163e3cd5ca..e0241cbc75 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -309,6 +309,7 @@ def create_roles_users(db, test_accounts) -> dict[str, User]: password="testtest", ) ) + db.session.commit() return {user.username: user.id for user in new_users} diff --git a/flexmeasures/data/models/data_sources.py b/flexmeasures/data/models/data_sources.py index 7bba06e467..0abf090738 100644 --- a/flexmeasures/data/models/data_sources.py +++ b/flexmeasures/data/models/data_sources.py @@ -178,8 +178,8 @@ def get_data_source_info(cls: type) -> dict: @property def data_source(self) -> "DataSource": """DataSource property derived from the `source_info`: `source_type` (scheduler, forecaster or reporter), `model` (e.g AggregatorReporter) - and `attributes`. It looks for a data source in the database the marges the `source_info` and, in case of not finding any, it creates a new one. - This property gets created once and it's cached for the rest of the lifetime of the DataGenerator object. + and `attributes`. It looks for a data source in the database that matches the `source_info` and, in case of not finding any, it creates a new one. + This property gets created once, and it's cached for the rest of the lifetime of the DataGenerator object. """ from flexmeasures.data.services.data_sources import get_or_create_source diff --git a/flexmeasures/data/services/utils.py b/flexmeasures/data/services/utils.py index 689190f370..dcee416923 100644 --- a/flexmeasures/data/services/utils.py +++ b/flexmeasures/data/services/utils.py @@ -7,7 +7,6 @@ from copy import deepcopy import inspect -import click from sqlalchemy import JSON, String, cast, literal from flask import current_app from rq import Queue @@ -130,7 +129,6 @@ def get_or_create_model( model = model_class(**init_kwargs) db.session.add(model) db.session.flush() # assign ID - click.echo(f"Created {repr(model)}") return model diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 18f4090cb8..1c79c9cafe 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -100,6 +100,7 @@ "/api/ops/ping": {}, "/api/ops/getLatestTaskRun": {}, "/api/ops/postLatestTaskRun": {}, + "/api/ops/logs": {}, "/api/v3_0/sensors/{id}": { "delete": { "summary": "Delete a sensor", diff --git a/flexmeasures/utils/coding_utils.py b/flexmeasures/utils/coding_utils.py index 1270d4d516..629d14c1c9 100644 --- a/flexmeasures/utils/coding_utils.py +++ b/flexmeasures/utils/coding_utils.py @@ -231,3 +231,42 @@ def __hash__(self): f"Unhashable object: {self} has no ID. Consider calling `db.session.flush()` before using {type(self).__name__} objects in sets or as dictionary keys." ) return hash(self.id) + + +def only_if_timer_due(*kwarg_names): + """ + Decorator that runs a method only if its timer is due. + + Timer name is derived from: + " for , , ..." + + :param kwarg_names: names of kwargs to include in the timer name + """ + + def decorator(func): + sig = inspect.signature(func) # get function signature + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + timer_name = func.__name__ # start with the function name + + if kwarg_names: + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + + values = [] + for name in kwarg_names: + if name in bound_args.arguments: + values.append(str(bound_args.arguments[name])) + else: + raise ValueError(f"Keyword '{name}' not found in function call") + + timer_name += " for " + ", ".join(values) + + if not self._is_timer_due(timer_name): + return # skip if timer not due + return func(self, *args, **kwargs) + + return wrapper + + return decorator diff --git a/flexmeasures/utils/config_utils.py b/flexmeasures/utils/config_utils.py index 7102372b88..876c97b8f2 100644 --- a/flexmeasures/utils/config_utils.py +++ b/flexmeasures/utils/config_utils.py @@ -27,6 +27,12 @@ "version": 1, "formatters": { "default": {"format": "[FLEXMEASURES][%(asctime)s] %(levelname)s: %(message)s"}, + "s2python": { + "format": "\033[94m[FLEXMEASURES][%(asctime)s][%(name)s] %(levelname)s: %(message)s\033[0m" + }, # blue + "s2rm": { + "format": "\033[91m[FLEXMEASURES][%(asctime)s][%(name)s] %(levelname)s: %(message)s\033[0m" + }, "detail": { "format": "[FLEXMEASURES][%(asctime)s] %(levelname)s: %(message)s [logged in %(pathname)s:%(lineno)d]" }, @@ -37,6 +43,16 @@ "stream": sys.stdout, "formatter": "default", }, + "s2console": { # handler specific to s2python + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "s2python", + }, + "s2rmconsole": { # handler specific to s2python-rm + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "s2rm", + }, "file": { "class": "logging.handlers.RotatingFileHandler", "level": "INFO", @@ -46,7 +62,15 @@ "backupCount": 6, }, }, - "root": {"level": "INFO", "handlers": ["console", "file"], "propagate": True}, + "loggers": { + "root": {"level": "INFO", "handlers": ["console", "file"], "propagate": True}, + "s2python": {"level": "DEBUG", "handlers": ["s2console"], "propagate": False}, + "flexmeasures_s2.rm": { + "level": "DEBUG", + "handlers": ["s2rmconsole"], + "propagate": False, + }, + }, } diff --git a/flexmeasures/utils/time_utils.py b/flexmeasures/utils/time_utils.py index 0c9ba62cf0..a3b7b765bd 100644 --- a/flexmeasures/utils/time_utils.py +++ b/flexmeasures/utils/time_utils.py @@ -21,6 +21,13 @@ def server_now() -> datetime: return datetime.now(get_timezone()) +def floored_server_now(resolution: timedelta) -> datetime: + """Return the current server time floored to the nearest multiple of `resolution`.""" + ref = pytz.utc.localize(datetime.min) + _tz = get_timezone() + return (ref + ((server_now() - ref) // resolution) * resolution).astimezone(_tz) + + def ensure_local_timezone( dt: pd.Timestamp | datetime, tz_name: str = "Europe/Amsterdam" ) -> pd.Timestamp | datetime: diff --git a/flexmeasures/ws/__init__.py b/flexmeasures/ws/__init__.py new file mode 100644 index 0000000000..ab877f4798 --- /dev/null +++ b/flexmeasures/ws/__init__.py @@ -0,0 +1,18 @@ +import importlib +import pkgutil +from flask import Blueprint, current_app +from flask_security import auth_token_required + +from flask_sock import Sock + +sock = Sock() + + +def import_all_modules(package_name): + package = importlib.import_module(package_name) + for _, name, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module(f"{package_name}.{name}") + + +# we need to import all the modules to run the route decorators +import_all_modules("flexmeasures.ws") diff --git a/flexmeasures/ws/ping1.py b/flexmeasures/ws/ping1.py new file mode 100644 index 0000000000..abea9ed18e --- /dev/null +++ b/flexmeasures/ws/ping1.py @@ -0,0 +1,31 @@ +import logging +from flexmeasures.ws import sock +from flask import current_app +from flexmeasures import Sensor +from sqlalchemy import select, func +import uuid +import json + +logger = logging.getLogger(__name__) + + +@sock.route("/ping1") +async def echo1(ws): + headers = ws.environ # Access all headers from the connection + client_id = str(uuid.uuid4()) + + logger.info("-----------------------------------------") + logger.info(f"Received headers: {headers}") + logger.info("-----------------------------------------") + logger.info(f"Type of ws: {type(ws)}") + logger.info(f"Client ID: {client_id}") + await ws.send( + json.dumps({"type": "metadata", "headers": {"X-Server-Header": "ServerValue"}}) + ) + while True: + data = await ws.receive() + logger.error("ping1>" + data) + if data == "close": + break + # sensors = current_app.db.session.execute(select(func.count(Sensor.id))).scalar() + await ws.send(data) diff --git a/flexmeasures/ws/ping2.py b/flexmeasures/ws/ping2.py new file mode 100644 index 0000000000..a072f3f600 --- /dev/null +++ b/flexmeasures/ws/ping2.py @@ -0,0 +1,14 @@ +import logging +from flexmeasures.ws import sock + +logger = logging.Logger(__name__) + + +@sock.route("/ping2") +def echo2(ws): + while True: + data = ws.receive() + logger.error("ping2>" + data) + if data == "close": + break + ws.send(data) diff --git a/flexmeasures/ws/tests/__init__.py b/flexmeasures/ws/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flexmeasures/ws/tests/conftest.py b/flexmeasures/ws/tests/conftest.py new file mode 100644 index 0000000000..077e447f68 --- /dev/null +++ b/flexmeasures/ws/tests/conftest.py @@ -0,0 +1,31 @@ +import threading +import time + +import pytest +import pytest_asyncio +import websockets +from werkzeug.serving import make_server + + +@pytest.fixture(scope="module") +def server(app): + """Run Flask app with Sock in a thread for testing WebSocket""" + srv = make_server("127.0.0.1", 5005, app) + thread = threading.Thread(target=srv.serve_forever) + thread.start() + time.sleep(0.1) # wait for server to start + yield "ws://127.0.0.1:5005" + srv.shutdown() + thread.join() + + +@pytest_asyncio.fixture +async def connect_to_ws(server): + """Yield a callable to connect to a given WS endpoint by name.""" + + async def connect(endpoint_name): + url = f"{server}/{endpoint_name}" + conn = await websockets.connect(url) + return conn + + yield connect diff --git a/flexmeasures/ws/tests/test_s2_client_rm.py b/flexmeasures/ws/tests/test_s2_client_rm.py new file mode 100644 index 0000000000..5ebe123090 --- /dev/null +++ b/flexmeasures/ws/tests/test_s2_client_rm.py @@ -0,0 +1,24 @@ +import pytest + +import logging +import websockets + +logger = logging.getLogger("s2python") +SERVER_URL = "ws://127.0.0.1:5000" + + +@pytest.mark.asyncio +async def test_ping2_echo(connect_to_ws): + + # Connect to WS endpoint + ws = await connect_to_ws("ping2") + + # Send a message + await ws.send("hello") + resp = await ws.recv() + assert resp == "hello", "echo should return the same message" + + # Trigger server-side close + await ws.send("close") + with pytest.raises(websockets.exceptions.ConnectionClosedOK): + await ws.recv(), "expected that, after sending 'close', server breaks loop; connection closes" diff --git a/flexmeasures/ws/v1.py b/flexmeasures/ws/v1.py new file mode 100644 index 0000000000..741d26a37f --- /dev/null +++ b/flexmeasures/ws/v1.py @@ -0,0 +1,38 @@ +import logging +from flexmeasures.ws import sock +from flask import current_app +from flexmeasures import Sensor +from sqlalchemy import select, func +import json + +logger = logging.Logger(__name__) + + +@sock.route("/v1") +def header_test(ws): + # Get all headers + all_headers = { + k[5:].lower().replace("_", "-"): v + for k, v in ws.environ.items() + if k.startswith("HTTP_") + } + + # Get specific header if needed + custom_header = ws.environ.get("HTTP_X_CUSTOM_HEADER") + # show the type of ws + logger.info(f"Type of ws: {type(ws)}") + logger.info(f"All headers: {all_headers}") + logger.info(f"Custom header: {custom_header}") + + # Send initial message with metadata + ws.send( + json.dumps({"type": "metadata", "headers": {"X-Server-Header": "ServerValue"}}) + ) + + while True: + data = ws.receive() + logger.error("v1>" + data) + if data == "close": + break + sensors = current_app.db.session.execute(select(func.count(Sensor.id))).scalar() + ws.send(str(sensors)) diff --git a/requirements/3.10/app.txt b/requirements/3.10/app.txt index 7220e0ba15..ec391111ed 100644 --- a/requirements/3.10/app.txt +++ b/requirements/3.10/app.txt @@ -35,6 +35,8 @@ attrs==25.4.0 # referencing babel==2.17.0 # via py-moneyed +binapy==0.8.0 + # via jwskate blinker==1.9.0 # via # flask @@ -59,6 +61,7 @@ click==8.1.8 # click-default-group # flask # rq + # s2-python click-default-group==1.2.4 # via -r requirements/app.in cloudpickle==3.1.2 @@ -72,6 +75,7 @@ croniter==6.0.0 cryptography==46.0.3 # via # flask-security-too + # jwskate # pyopenssl # webauthn cycler==0.12.1 @@ -100,6 +104,7 @@ flask==3.1.2 # flask-migrate # flask-principal # flask-security-too + # flask-sock # flask-sqlalchemy # flask-sslify # flask-swagger-ui @@ -126,6 +131,8 @@ flask-principal==0.4.0 # via flask-security-too flask-security-too[fsqla,mfa]==5.7.1 # via -r requirements/app.in +flask-sock==0.7.0 + # via -r requirements/app.in flask-sqlalchemy==3.1.1 # via # -r requirements/app.in @@ -149,6 +156,8 @@ greenlet==3.2.4 # via sqlalchemy holidays==0.86 # via u8darts +h11==0.16.0 + # via wsproto humanize==4.14.0 # via -r requirements/app.in idna==3.11 @@ -190,6 +199,8 @@ jsonschema-specifications==2025.9.1 # via jsonschema kiwisolver==1.4.9 # via matplotlib +jwskate==0.12.2 + # via s2-python libpass==1.9.3 # via flask-security-too lightgbm==4.6.0 @@ -316,7 +327,9 @@ py-moneyed==3.0 pycparser==2.23 # via cffi pydantic==2.12.5 - # via -r requirements/app.in + # via + # -r requirements/app.in + # s2-python pydantic-core==2.41.5 # via pydantic pyluach==2.3.0 @@ -347,6 +360,7 @@ pytz==2025.2 # -r requirements/app.in # croniter # pandas + # s2-python # timely-beliefs # timetomodel pyyaml==6.0.3 @@ -372,6 +386,7 @@ referencing==0.37.0 requests==2.32.5 # via # requests-file + # s2-python # tldextract # u8darts requests-file==3.0.1 @@ -386,6 +401,8 @@ rq==2.6.1 # rq-dashboard rq-dashboard==0.8.6 # via -r requirements/app.in +s2-python[ws] @ git+https://github.com/flexiblepower/s2-python.git@feat/flask_server + # via -r requirements/app.in scikit-base==0.13.0 # via sktime scikit-learn==1.7.2 @@ -411,6 +428,8 @@ sentry-sdk[flask]==2.46.0 # via -r requirements/app.in shap==0.49.1 # via u8darts +simple-websocket==1.1.0 + # via flask-sock six==1.17.0 # via python-dateutil sktime==0.40.1 @@ -452,9 +471,11 @@ typing-extensions==4.15.0 # via # alembic # altair + # binapy # cryptography # flexcache # flexparser + # jwskate # pint # py-moneyed # pydantic @@ -487,6 +508,8 @@ webargs==8.7.1 # via -r requirements/app.in webauthn==2.7.0 # via flask-security-too +websockets==13.1 + # via s2-python werkzeug==3.1.4 # via # -r requirements/app.in @@ -495,6 +518,8 @@ werkzeug==3.1.4 # flask-login workalendar==17.0.0 # via -r requirements/app.in +wsproto==1.2.0 + # via simple-websocket wtforms==3.2.1 # via # flask-security-too diff --git a/requirements/3.11/app.txt b/requirements/3.11/app.txt index b14637e3e0..fc0375e482 100644 --- a/requirements/3.11/app.txt +++ b/requirements/3.11/app.txt @@ -33,6 +33,8 @@ attrs==25.4.0 # referencing babel==2.17.0 # via py-moneyed +binapy==0.8.0 + # via jwskate blinker==1.9.0 # via # flask @@ -57,6 +59,7 @@ click==8.1.8 # click-default-group # flask # rq + # s2-python click-default-group==1.2.4 # via -r requirements/app.in cloudpickle==3.1.2 @@ -70,6 +73,7 @@ croniter==6.0.0 cryptography==46.0.3 # via # flask-security-too + # jwskate # pyopenssl # webauthn cycler==0.12.1 @@ -98,6 +102,7 @@ flask==3.1.2 # flask-migrate # flask-principal # flask-security-too + # flask-sock # flask-sqlalchemy # flask-sslify # flask-swagger-ui @@ -126,6 +131,8 @@ flask-security-too[fsqla,mfa]==5.7.1 # via # -r requirements/app.in # flask-security-too +flask-sock==0.7.0 + # via -r requirements/app.in flask-sqlalchemy==3.1.1 # via # -r requirements/app.in @@ -147,6 +154,8 @@ fonttools==4.61.0 # via matplotlib greenlet==3.2.4 # via sqlalchemy +h11==0.16.0 + # via wsproto holidays==0.86 # via u8darts humanize==4.14.0 @@ -188,6 +197,8 @@ jsonschema==4.25.1 # via altair jsonschema-specifications==2025.9.1 # via jsonschema +jwskate==0.12.2 + # via s2-python kiwisolver==1.4.9 # via matplotlib libpass==1.9.3 @@ -315,7 +326,9 @@ py-moneyed==3.0 pycparser==2.23 # via cffi pydantic==2.12.5 - # via -r requirements/app.in + # via + # -r requirements/app.in + # s2-python pydantic-core==2.41.5 # via pydantic pyluach==2.3.0 @@ -346,6 +359,7 @@ pytz==2025.2 # -r requirements/app.in # croniter # pandas + # s2-python # timely-beliefs # timetomodel pyyaml==6.0.3 @@ -371,6 +385,7 @@ referencing==0.37.0 requests==2.32.5 # via # requests-file + # s2-python # tldextract # u8darts requests-file==3.0.1 @@ -385,6 +400,8 @@ rq==2.6.1 # rq-dashboard rq-dashboard==0.8.6 # via -r requirements/app.in +s2-python[ws] @ git+https://github.com/flexiblepower/s2-python.git@feat/flask_server + # via -r requirements/app.in scikit-base==0.13.0 # via sktime scikit-learn==1.7.2 @@ -410,6 +427,8 @@ sentry-sdk[flask]==2.46.0 # via -r requirements/app.in shap==0.49.1 # via u8darts +simple-websocket==1.1.0 + # via flask-sock six==1.17.0 # via python-dateutil sktime==0.40.1 @@ -449,8 +468,10 @@ typing-extensions==4.15.0 # via # alembic # altair + # binapy # flexcache # flexparser + # jwskate # pint # py-moneyed # pydantic @@ -483,6 +504,8 @@ webargs==8.7.1 # via -r requirements/app.in webauthn==2.7.0 # via flask-security-too +websockets==13.1 + # via s2-python werkzeug==3.1.4 # via # -r requirements/app.in @@ -491,6 +514,8 @@ werkzeug==3.1.4 # flask-login workalendar==17.0.0 # via -r requirements/app.in +wsproto==1.2.0 + # via simple-websocket wtforms==3.2.1 # via # flask-security-too diff --git a/requirements/3.12/app.txt b/requirements/3.12/app.txt index 050d6cb3d7..d652c0c8f2 100644 --- a/requirements/3.12/app.txt +++ b/requirements/3.12/app.txt @@ -98,6 +98,7 @@ flask==3.1.2 # flask-migrate # flask-principal # flask-security-too + # flask-sock # flask-sqlalchemy # flask-sslify # flask-swagger-ui @@ -126,6 +127,8 @@ flask-security-too[fsqla,mfa]==5.7.1 # via # -r requirements/app.in # flask-security-too +flask-sock==0.7.0 + # via -r requirements/app.in flask-sqlalchemy==3.1.1 # via # -r requirements/app.in @@ -147,6 +150,8 @@ fonttools==4.61.0 # via matplotlib greenlet==3.2.4 # via sqlalchemy +h11==0.16.0 + # via wsproto holidays==0.86 # via u8darts humanize==4.14.0 @@ -410,6 +415,8 @@ sentry-sdk[flask]==2.46.0 # via -r requirements/app.in shap==0.49.1 # via u8darts +simple-websocket==1.1.0 + # via flask-sock six==1.17.0 # via python-dateutil sktime==0.40.1 @@ -491,6 +498,8 @@ werkzeug==3.1.4 # flask-login workalendar==17.0.0 # via -r requirements/app.in +wsproto==1.2.0 + # via simple-websocket wtforms==3.2.1 # via # flask-security-too diff --git a/requirements/3.9/app.txt b/requirements/3.9/app.txt index 27c13c32d2..157a96a6f8 100644 --- a/requirements/3.9/app.txt +++ b/requirements/3.9/app.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --output-file=requirements/3.9/app.txt requirements/app.in @@ -106,6 +106,7 @@ flask==3.1.2 # flask-migrate # flask-principal # flask-security-too + # flask-sock # flask-sqlalchemy # flask-sslify # flask-swagger-ui @@ -132,6 +133,8 @@ flask-principal==0.4.0 # via flask-security-too flask-security-too[fsqla,mfa]==5.6.2 # via -r requirements/app.in +flask-sock==0.7.0 + # via -r requirements/app.in flask-sqlalchemy==3.1.1 # via # -r requirements/app.in @@ -157,6 +160,8 @@ fugue==0.9.3 # via statsforecast greenlet==3.2.4 # via sqlalchemy +h11==0.16.0 + # via wsproto holidays==0.83 # via u8darts humanize==4.13.0 @@ -169,13 +174,9 @@ idna==3.11 importlib-metadata==8.7.0 # via # -r requirements/app.in - # flask # timely-beliefs - # typeguard importlib-resources==6.5.2 - # via - # flask-security-too - # matplotlib + # via flask-security-too inflect==7.5.0 # via -r requirements/app.in inflection==0.5.1 @@ -443,6 +444,8 @@ sentry-sdk[flask]==2.46.0 # via -r requirements/app.in shap==0.49.1 # via u8darts +simple-websocket==1.1.0 + # via flask-sock six==1.17.0 # via # python-dateutil @@ -502,7 +505,6 @@ typing-extensions==4.15.0 # cryptography # flexcache # flexparser - # marshmallow-sqlalchemy # pint # py-moneyed # pydantic @@ -546,6 +548,8 @@ werkzeug==3.1.4 # flask-login workalendar==17.0.0 # via -r requirements/app.in +wsproto==1.2.0 + # via simple-websocket wtforms==3.2.1 # via # flask-security-too @@ -557,9 +561,7 @@ xgboost==2.1.4 xlrd==2.0.2 # via -r requirements/app.in zipp==3.23.0 - # via - # importlib-metadata - # importlib-resources + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/app.in b/requirements/app.in index 0247a3ce52..cc45b479a0 100644 --- a/requirements/app.in +++ b/requirements/app.in @@ -80,3 +80,4 @@ dictdiffer>=0.9.0 # Forecaster: TrainPredictPipeline lightgbm u8darts>=0.29.0 +flask-sock diff --git a/test_ws_client.py b/test_ws_client.py new file mode 100644 index 0000000000..9c587c8fd3 --- /dev/null +++ b/test_ws_client.py @@ -0,0 +1,63 @@ +from simple_websocket import Client, ConnectionClosed # type: ignore +import json +import sys + +import asyncio +import websockets + + +async def main(): + uri = "ws://127.0.0.1:5000/ping1" + headers = { + "X-Custom-Header": "SomeValue", + "Authorization": "Bearer YourToken", + } + + async with websockets.connect(uri, extra_headers=headers) as ws: + while True: + data = input("> ") + await ws.send(data) + response = await ws.recv() + print(f"< {response}") + + +if __name__ == "__main__": + asyncio.run(main()) + +# def main(): +# headers = { +# "X-Custom-Header": "SomeValue", +# # 'Authorization': 'Bearer YourToken', +# } +# ws = Client.connect("ws://127.0.0.1:5000/ping1", headers=headers) +# try: +# print("Connected to the WebSocket server!") +# +# # Get initial metadata message +# initial_msg = json.loads(ws.receive()) +# print(initial_msg) +# if initial_msg.get("type") != "metadata": +# print("ERROR: Server metadata not received!") +# ws.close() +# sys.exit(1) +# +# server_header = initial_msg.get("headers", {}).get("X-Server-Header") +# if not server_header: +# print("ERROR: Server header not found in metadata!") +# ws.close() +# sys.exit(1) +# print(f"Server header received: {server_header}") +# +# while True: +# data = input("> ") +# ws.send(data) +# data = ws.receive() +# print(f"< {data}") +# +# except (KeyboardInterrupt, EOFError, ConnectionClosed) as e: +# print(f"Connection closed: {e}") +# ws.close() + + +if __name__ == "__main__": + main()