diff --git a/pyproject.toml b/pyproject.toml index 9dd3fe3..f6a6118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ mobi-view = "MoBI_View.main:main" [dependency-groups] dev = [ "pytest>=8.3.5", + "pytest-asyncio>=0.23.0", "mypy>=1.15.0", "pre-commit>=4.2.0", "pytest-cov>=6.1.1", @@ -33,6 +34,7 @@ docs = ["pdoc>=15.0.3"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] +asyncio_mode = "auto" [tool.mypy] ignore_missing_imports = true diff --git a/src/MoBI_View/main.py b/src/MoBI_View/main.py index c425f74..25e2a6b 100644 --- a/src/MoBI_View/main.py +++ b/src/MoBI_View/main.py @@ -10,6 +10,7 @@ from MoBI_View.core import discovery from MoBI_View.presenters import main_app_presenter +from MoBI_View.web import server as web_server logging.basicConfig( level=logging.INFO, @@ -38,22 +39,22 @@ def _launch() -> None: def main() -> None: - """Discovers LSL streams and creates presenter (web server in future branch).""" + """Discovers LSL streams, creates presenter, and starts the web server.""" logger.info("Discovering LSL streams...") inlets = discovery.discover_and_create_inlets() if not inlets: logger.info("No LSL streams found.") - logger.info( - "Future: Use 'Discover Streams' button in web UI to search for streams." - ) + logger.info("Use 'Discover Streams' button in web UI to search for streams.") else: logger.info("Found %d stream(s)", len(inlets)) presenter = main_app_presenter.MainAppPresenter(data_inlets=inlets) logger.info("Created presenter with %d inlet(s)", len(presenter.data_inlets)) - logger.info("Note: Web server functionality will be added in future branch") + + schedule_browser_launch() + web_server.run_server(presenter) if __name__ == "__main__": diff --git a/src/MoBI_View/web/server.py b/src/MoBI_View/web/server.py index cd07235..6737d20 100644 --- a/src/MoBI_View/web/server.py +++ b/src/MoBI_View/web/server.py @@ -1,13 +1,17 @@ """WebSocket server for MoBI-View real-time data streaming. -This module provides WebSocket connection handling and static file serving -via the websockets process_request hook. +This module provides WebSocket connection handling, static file serving +via the websockets process_request hook, and the `run_server` entry +point that orchestrates the broadcaster and server lifecycle. """ +import asyncio +import functools import json import logging import mimetypes import pathlib +import signal from urllib import parse from websockets import datastructures, http11 @@ -179,3 +183,86 @@ def _error_response(status_code: int, reason: str) -> http11.Response: """Build a plain-text HTTP error response.""" headers = datastructures.Headers([("Content-Type", "text/plain")]) return http11.Response(status_code, reason, headers, reason.encode()) + + +def run_server( + presenter: main_app_presenter.MainAppPresenter, + host: str = "localhost", + port: int = 8765, +) -> None: + """Entry point that starts the server runtime from synchronous code. + + This function bridges the synchronous application startup path + (`main()`) into the async server lifecycle by running + `_run_server_async` in a fresh event loop. + + The async lifecycle handles broadcaster startup, WebSocket server + startup, HTTP static-file routing via `process_request`, and + graceful shutdown on SIGINT/SIGTERM. + + Port 8765 is conventional for WebSocket development servers, + used by the `websockets` library examples and tutorials. + + Args: + presenter: The MainAppPresenter providing stream data. + host: The hostname to bind to. + port: The port to listen on. + """ + asyncio.run(_run_server_async(presenter, host, port)) + + +async def _run_server_async( + presenter: main_app_presenter.MainAppPresenter, + host: str, + port: int, +) -> None: + """Starts and manages the async WebSocket server lifecycle. + + This initializes the Broadcaster's background polling thread and + starts the WebSocket server on the specified host and port. It acts + as the main router, serving static files for plain HTTP requests + and directing WebSocket connections to the streaming handler. + + The server runs continuously until it receives a shutdown signal + (SIGINT/SIGTERM), ensuring a graceful teardown of the broadcaster. + + Args: + presenter: The MainAppPresenter providing stream data. + host: The hostname to bind to. + port: The port to listen on. + """ + active_broadcaster = broadcaster.Broadcaster(presenter) + active_broadcaster.start() + + stop_event = asyncio.Event() + _register_shutdown_signals(stop_event) + + handler = functools.partial( + ws_handler, + active_broadcaster=active_broadcaster, + presenter=presenter, + ) + + try: + async with server.serve( + handler, + host, + port, + process_request=process_request, + ): + logger.info("Server listening on ws://%s:%d", host, port) + await stop_event.wait() + finally: + active_broadcaster.stop() + logger.info("Server shut down") + + +def _register_shutdown_signals(stop_event: asyncio.Event) -> None: + """Register SIGINT and SIGTERM to set the stop event. + + Args: + stop_event: The asyncio event to set when a signal is received. + """ + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) diff --git a/tests/smoke/test_main.py b/tests/smoke/test_main.py index 85642e7..c0eef4c 100644 --- a/tests/smoke/test_main.py +++ b/tests/smoke/test_main.py @@ -5,6 +5,7 @@ import time from typing import Generator +from unittest.mock import MagicMock, patch import numpy as np import pytest @@ -12,6 +13,7 @@ from pylsl import outlet as pylsl_outlet from pylsl import resolve as pylsl_resolve +from MoBI_View import main from MoBI_View.core import data_inlet @@ -124,3 +126,46 @@ def test_no_streams() -> None: data_inlets.append(inlet) assert isinstance(data_inlets, list) + + +def test_main_discovers_streams_and_starts_server() -> None: + """Tests main() wires discovery, presenter, browser launch, and server.""" + mock_inlet = MagicMock() + mock_presenter = MagicMock() + + with ( + patch.object( + main.discovery, "discover_and_create_inlets", return_value=[mock_inlet] + ) as mock_discover, + patch.object( + main.main_app_presenter, + "MainAppPresenter", + return_value=mock_presenter, + ) as mock_pres_cls, + patch.object(main, "schedule_browser_launch") as mock_browser, + patch.object(main.web_server, "run_server") as mock_run, + ): + main.main() + + mock_discover.assert_called_once() + mock_pres_cls.assert_called_once_with(data_inlets=[mock_inlet]) + mock_browser.assert_called_once() + mock_run.assert_called_once_with(mock_presenter) + + +def test_schedule_browser_launch_opens_browser() -> None: + """Tests schedule_browser_launch schedules a browser open call.""" + with patch.object(main.webbrowser, "open") as mock_open: + with patch.object(main.threading, "Timer") as mock_timer_cls: + mock_timer = MagicMock() + mock_timer_cls.return_value = mock_timer + + main.schedule_browser_launch() + + mock_timer_cls.assert_called_once() + mock_timer.start.assert_called_once() + + callback = mock_timer_cls.call_args[0][1] + callback() + + mock_open.assert_called_once_with("http://localhost:8765", new=2, autoraise=True) diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 5bfdebf..23ef4d5 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -3,7 +3,8 @@ import asyncio import json import pathlib -from unittest.mock import AsyncMock, MagicMock, patch +import signal +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from websockets import datastructures @@ -341,3 +342,75 @@ def test_is_within_static_dir( with patch.object(server, "STATIC_DIR", static): assert server._is_within_static_dir(target) is expected + + +def test_run_server_calls_asyncio_run(mock_presenter: MagicMock) -> None: + """Tests run_server delegates to _run_server_async via asyncio.run.""" + with patch.object( + server, "_run_server_async", new_callable=AsyncMock + ) as mock_async: + server.run_server(mock_presenter, host="0.0.0.0", port=9000) + + mock_async.assert_awaited_once_with(mock_presenter, "0.0.0.0", 9000) + + +async def test_run_server_async_starts_and_stops_broadcaster( + mock_presenter: MagicMock, +) -> None: + """Tests _run_server_async creates, starts, and stops the broadcaster.""" + mock_bc = MagicMock(spec=broadcaster.Broadcaster) + mock_serve = AsyncMock() + mock_serve.__aenter__ = AsyncMock() + mock_serve.__aexit__ = AsyncMock(return_value=False) + + with ( + patch.object( + server.broadcaster, "Broadcaster", return_value=mock_bc + ) as mock_bc_cls, + patch.object(server.server, "serve", return_value=mock_serve) as mock_serve_fn, + patch.object(server, "_register_shutdown_signals") as mock_signals, + ): + mock_signals.side_effect = lambda event: event.set() + + await server._run_server_async(mock_presenter, "localhost", 8765) + + mock_bc_cls.assert_called_once_with(mock_presenter) + mock_bc.start.assert_called_once() + mock_bc.stop.assert_called_once() + + _, serve_kwargs = mock_serve_fn.call_args + assert serve_kwargs["process_request"] is server.process_request + + +async def test_run_server_async_stops_broadcaster_on_error( + mock_presenter: MagicMock, +) -> None: + """Tests broadcaster is stopped even when the server raises.""" + mock_bc = MagicMock(spec=broadcaster.Broadcaster) + mock_serve = AsyncMock() + mock_serve.__aenter__ = AsyncMock(side_effect=OSError("address in use")) + mock_serve.__aexit__ = AsyncMock(return_value=False) + + with ( + patch.object(server.broadcaster, "Broadcaster", return_value=mock_bc), + patch.object(server.server, "serve", return_value=mock_serve), + patch.object(server, "_register_shutdown_signals"), + ): + with pytest.raises(OSError, match="address in use"): + await server._run_server_async(mock_presenter, "localhost", 8765) + + mock_bc.stop.assert_called_once() + + +async def test_register_shutdown_signals_registers_both_signals() -> None: + """Tests both SIGINT and SIGTERM are registered on the event loop.""" + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + + with patch.object(loop, "add_signal_handler") as mock_add: + server._register_shutdown_signals(stop_event) + + mock_add.assert_has_calls( + [call(signal.SIGINT, stop_event.set), call(signal.SIGTERM, stop_event.set)], + any_order=False, + ) diff --git a/uv.lock b/uv.lock index fa1935a..a3cb6ef 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.10, <3.13" +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -223,6 +232,7 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "ruff" }, @@ -244,6 +254,7 @@ dev = [ { name = "mypy", specifier = ">=1.15.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-mock", specifier = ">=3.10.0" }, { name = "ruff", specifier = ">=0.11.7" }, @@ -446,6 +457,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, +] + [[package]] name = "pytest-cov" version = "6.2.1"