diff --git a/pyproject.toml b/pyproject.toml index a21e337144..aecd023b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "annotated_types", "caproto", "fastapi[all]", - "flask-restful", + "uvicorn", "jupyterlab", "matplotlib", "nexgen >= 0.11.0", diff --git a/src/mx_bluesky/hyperion/plan_runner_api.py b/src/mx_bluesky/hyperion/plan_runner_api.py index 927c3a70d4..eb08967409 100644 --- a/src/mx_bluesky/hyperion/plan_runner_api.py +++ b/src/mx_bluesky/hyperion/plan_runner_api.py @@ -1,55 +1,45 @@ -from threading import Thread +from typing import Annotated -from flask import Flask -from flask_restful import Api, Resource +import uvicorn +from fastapi import Depends, FastAPI from mx_bluesky.common.utils.log import LOGGER from mx_bluesky.hyperion.plan_runner import PlanRunner +app = FastAPI() -# Ignore this function for code coverage as there is no way to shut down -# a server once it is started. -def create_server_for_udc(runner: PlanRunner, port: int) -> Thread: # pragma: no cover - """Create a minimal API for Hyperion UDC mode""" - app = create_app_for_udc(runner) - - flask_thread = Thread( - target=app.run, - kwargs={"host": "0.0.0.0", "port": port}, - daemon=True, - ) - flask_thread.start() - LOGGER.info(f"Hyperion now listening on {port}") - return flask_thread +_plan_runner: PlanRunner -def create_app_for_udc(runner: PlanRunner): - app = Flask(__name__) - api = Api(app) - api.add_resource(StatusResource, "/status", resource_class_args=[runner]) - api.add_resource(CallbackLiveness, "/callbackPing", resource_class_args=[runner]) - return app +# Ignore this function for code coverage as there is no way to shut down +# a server once it is started. +def create_server_for_udc( + runner: PlanRunner, port: int +) -> uvicorn.Server: # pragma: no cover + # register resources with the app via instantiation + global _plan_runner + _plan_runner = runner + """Create a minimal API for Hyperion UDC mode""" + config = uvicorn.Config("mx_bluesky.hyperion.plan_runner_api:app", port=port) + server = uvicorn.Server(config) + server.run() -class StatusResource(Resource): - """Status endpoint, used by k8s healthcheck probe""" + LOGGER.info(f"Hyperion now listening on {port}") + return server - def __init__(self, runner: PlanRunner): - super().__init__() - self._runner = runner - def get(self): - status = self._runner.current_status - return {"status": status.value} +def plan_runner() -> PlanRunner: + return _plan_runner -class CallbackLiveness(Resource): - """Called periodically by the external callbacks to indicate that they are still running""" +@app.get("/status") +async def get_status(runner: Annotated[PlanRunner, Depends(plan_runner)]): + status = runner.current_status + return {"status": status.value} - def __init__(self, runner: PlanRunner): - super().__init__() - self._runner = runner - def get(self): - LOGGER.debug("External callback ping received.") - self._runner.reset_callback_watchdog_timer() +@app.get("/callbackPing") +async def get_callback_ping(runner: Annotated[PlanRunner, Depends(plan_runner)]): + LOGGER.debug("External callback ping received.") + runner.reset_callback_watchdog_timer() diff --git a/tests/unit_tests/hyperion/test_plan_runner_api.py b/tests/unit_tests/hyperion/test_plan_runner_api.py index e9c08ab4a7..1800536e77 100644 --- a/tests/unit_tests/hyperion/test_plan_runner_api.py +++ b/tests/unit_tests/hyperion/test_plan_runner_api.py @@ -1,42 +1,41 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from flask import Flask -from flask.testing import FlaskClient +from fastapi import FastAPI +from fastapi.testclient import TestClient from mx_bluesky.common.parameters.constants import Status from mx_bluesky.hyperion.in_process_runner import InProcessRunner -from mx_bluesky.hyperion.plan_runner_api import ( - create_app_for_udc, -) +from mx_bluesky.hyperion.plan_runner_api import app @pytest.fixture() def mock_runner(): - return MagicMock(spec=InProcessRunner) + runner = MagicMock(spec=InProcessRunner) + with patch("mx_bluesky.hyperion.plan_runner_api._plan_runner", runner, create=True): + yield runner @pytest.fixture() def app_under_test(mock_runner): - app = create_app_for_udc(mock_runner) yield app @pytest.fixture() -def client(app_under_test: Flask) -> FlaskClient: - return app_under_test.test_client() +def client(app_under_test: FastAPI) -> TestClient: + return TestClient(app_under_test) def test_plan_runner_api_fetch_status(app_under_test, client, mock_runner): mock_runner.current_status = Status.BUSY response = client.get("/status") assert response.status_code == 200 - assert response.content_type == "application/json" - assert response.json["status"] == Status.BUSY.value + assert response.headers["Content-Type"] == "application/json" + assert response.json()["status"] == Status.BUSY.value def test_plan_runner_api_callback_liveness(app_under_test, client, mock_runner): response = client.get("/callbackPing") mock_runner.reset_callback_watchdog_timer.assert_called_once() assert response.status_code == 200 - assert response.content_type == "application/json" + assert response.headers["Content-Type"] == "application/json" diff --git a/uv.lock b/uv.lock index 197fe0bb1a..3f5c0c9d62 100644 --- a/uv.lock +++ b/uv.lock @@ -107,15 +107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] -[[package]] -name = "aniso8601" -version = "10.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/52179c4e3f1978d3d9a285f98c706642522750ef343e9738286130423730/aniso8601-10.0.1.tar.gz", hash = "sha256:25488f8663dd1528ae1f54f94ac1ea51ae25b4d531539b8bc707fed184d16845", size = 47190, upload-time = "2025-04-18T17:29:42.995Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/75/e0e10dc7ed1408c28e03a6cb2d7a407f99320eb953f229d008a7a6d05546/aniso8601-10.0.1-py2.py3-none-any.whl", hash = "sha256:eb19717fd4e0db6de1aab06f12450ab92144246b257423fe020af5748c0cb89e", size = 52848, upload-time = "2025-04-18T17:29:41.492Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -338,15 +329,6 @@ css = [ { name = "tinycss2", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "blosc2" version = "4.1.2" @@ -1031,38 +1013,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "click", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "itsdangerous", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "jinja2", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "markupsafe", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "werkzeug", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, -] - -[[package]] -name = "flask-restful" -version = "0.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aniso8601", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "flask", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "pytz", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "six", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/ce/a0a133db616ea47f78a41e15c4c68b9f08cab3df31eb960f61899200a119/Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37", size = 110453, upload-time = "2023-05-21T03:58:55.781Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/7b/f0b45f0df7d2978e5ae51804bb5939b7897b2ace24306009da0cc34d8d1f/Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b", size = 26217, upload-time = "2023-05-21T03:58:54.004Z" }, -] - [[package]] name = "flexcache" version = "0.3" @@ -2059,7 +2009,6 @@ dependencies = [ { name = "deepdiff", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "dls-dodal", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "fastapi", extra = ["all"], marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "flask-restful", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "jupyterlab", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "matplotlib", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "mysql-connector-python", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, @@ -2078,6 +2027,7 @@ dependencies = [ { name = "scanspec", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "scipy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "semver", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "uvicorn", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, ] [package.dev-dependencies] @@ -2123,7 +2073,6 @@ requires-dist = [ { name = "deepdiff" }, { name = "dls-dodal", git = "https://github.com/DiamondLightSource/dodal.git?rev=main" }, { name = "fastapi", extras = ["all"] }, - { name = "flask-restful" }, { name = "jupyterlab" }, { name = "matplotlib" }, { name = "mysql-connector-python", specifier = "==9.5.0" }, @@ -2142,6 +2091,7 @@ requires-dist = [ { name = "scanspec" }, { name = "scipy" }, { name = "semver" }, + { name = "uvicorn" }, ] [package.metadata.requires-dev] @@ -3415,15 +3365,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] -[[package]] -name = "pytz" -version = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -4523,18 +4464,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] -[[package]] -name = "werkzeug" -version = "3.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, -] - [[package]] name = "workflows" version = "3.3"