From 2bbb02ec85fcfe2dd61dd1d0073f6c4aa424bf26 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 21 Sep 2026 14:41:11 +0100 Subject: [PATCH 1/5] Make 'Content-Disposition' value an f-string --- src/murfey/server/api/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/murfey/server/api/bootstrap.py b/src/murfey/server/api/bootstrap.py index 42980ba8b..5bc382b72 100644 --- a/src/murfey/server/api/bootstrap.py +++ b/src/murfey/server/api/bootstrap.py @@ -1188,5 +1188,5 @@ def get_plugin_wheel(instrument_name: str, package: str): return None return FileResponse( wheel_path, - headers={"Content-Disposition": "attachment; filename={wheel_path.name}"}, + headers={"Content-Disposition": f"attachment; filename={wheel_path.name}"}, ) From feecaeef9dc2172e09b3c7096ca05b50eeb8c9c3 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 21 Sep 2026 18:16:09 +0100 Subject: [PATCH 2/5] Added API endpoint to render HTML page showing available plugin wheels for a given instrument --- src/murfey/server/api/bootstrap.py | 30 +++++++++++++++++++++++++++++ src/murfey/util/route_manifest.yaml | 7 +++++++ 2 files changed, 37 insertions(+) diff --git a/src/murfey/server/api/bootstrap.py b/src/murfey/server/api/bootstrap.py index 5bc382b72..4cf204890 100644 --- a/src/murfey/server/api/bootstrap.py +++ b/src/murfey/server/api/bootstrap.py @@ -1178,6 +1178,36 @@ def get_pypi_file( """ +@plugins.get("/instruments/{instrument_name}/", response_class=HTMLResponse) +def show_plugin_wheels(instrument_name: str): + """ + Shows plugin wheels that have been configured for this instrument + """ + machine_config = get_machine_config(instrument_name=instrument_name)[ + instrument_name + ] + # Construct links to download the individual packages with + links = "\n".join( + f'
  • {key}
  • ' + for key in machine_config.plugin_packages.keys() + ) + # Embed links in a HTML page + return f""" + + + + Packages + + +

    Available packages

    + + + + """ + + @plugins.get("/instruments/{instrument_name}/{package}", response_class=FileResponse) def get_plugin_wheel(instrument_name: str, package: str): machine_config = get_machine_config(instrument_name=instrument_name)[ diff --git a/src/murfey/util/route_manifest.yaml b/src/murfey/util/route_manifest.yaml index 7c319c344..558600dec 100644 --- a/src/murfey/util/route_manifest.yaml +++ b/src/murfey/util/route_manifest.yaml @@ -277,6 +277,13 @@ murfey.server.api.bootstrap.msys2: methods: - GET murfey.server.api.bootstrap.plugins: + - path: /plugins/instruments/{instrument_name}/ + function: show_plugin_wheels + path_params: + - name: instrument_name + type: str + methods: + - GET - path: /plugins/instruments/{instrument_name}/{package} function: get_plugin_wheel path_params: From 8b10454bd9f524a6893b3190a96f322879f12d87 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 21 Sep 2026 18:40:31 +0100 Subject: [PATCH 3/5] Added test for 'show_plugin_wheels' --- tests/server/api/test_bootstrap.py | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/server/api/test_bootstrap.py diff --git a/tests/server/api/test_bootstrap.py b/tests/server/api/test_bootstrap.py new file mode 100644 index 000000000..0307daae0 --- /dev/null +++ b/tests/server/api/test_bootstrap.py @@ -0,0 +1,71 @@ +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture + +from murfey.server.api.bootstrap import plugins as plugins_router +from murfey.util.api import url_path_for +from murfey.util.config import MachineConfig + + +def set_up_test_backend_client(): + """ + Helper function to set up a test backend server whose response can be inspected + to check that the endpoint function works as expected + """ + # Set up the backend server + backend_app = FastAPI() + backend_app.include_router(plugins_router) + return TestClient(backend_app) + + +@pytest.mark.parametrize("packages", ([], ["package_a"], ["package_a", "package_b"])) +def test_show_plugin_wheels( + mocker: MockerFixture, + packages: list[str], + tmp_path: Path, +): + # Set up test parameters + instrument_name = "murfey-test" + + # Mock the 'get_machine_config' return value + plugin_packages = {pkg: tmp_path / pkg for pkg in packages} + config = MachineConfig(plugin_packages=plugin_packages) + mock_get_machine_config = mocker.patch( + "murfey.server.api.bootstrap.get_machine_config", + return_value={instrument_name: config}, + ) + + # Set up the test backend client and the URL to poke + backend_server = set_up_test_backend_client() + backend_url_path = url_path_for( + "api.bootstrap.plugins", + "show_plugin_wheels", + instrument_name=instrument_name, + ) + + # Poke it and check that the calls and response are as expected + response = backend_server.get(backend_url_path) + mock_get_machine_config.assert_called_once_with(instrument_name=instrument_name) + assert response.status_code == 200 + + # Manually construct the HTML page + links = "\n".join(f'
  • {pkg}
  • ' for pkg in packages) + html_page = f""" + + + + Packages + + +

    Available packages

    +
      + {links} +
    + + + """ + # Check that it was constructed correctly + assert response.content.decode() == html_page From f067c6f35d7685285d2354a4f9a8b78098a648d8 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 21 Sep 2026 18:51:28 +0100 Subject: [PATCH 4/5] Raise exception if the plugin is not found instead of returning None --- src/murfey/server/api/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/murfey/server/api/bootstrap.py b/src/murfey/server/api/bootstrap.py index 4cf204890..ca1c3a1ac 100644 --- a/src/murfey/server/api/bootstrap.py +++ b/src/murfey/server/api/bootstrap.py @@ -1215,7 +1215,7 @@ def get_plugin_wheel(instrument_name: str, package: str): ] wheel_path = machine_config.plugin_packages.get(package) if wheel_path is None: - return None + raise HTTPException(status_code=404, detail=f"Package {package} not found") return FileResponse( wheel_path, headers={"Content-Disposition": f"attachment; filename={wheel_path.name}"}, From 7e87a6ccffcfaf23c5b48b99d40604ece06f03f4 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 21 Sep 2026 18:51:52 +0100 Subject: [PATCH 5/5] Added test for 'get_plugin_wheel' --- tests/server/api/test_bootstrap.py | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/server/api/test_bootstrap.py b/tests/server/api/test_bootstrap.py index 0307daae0..6f6de2ce2 100644 --- a/tests/server/api/test_bootstrap.py +++ b/tests/server/api/test_bootstrap.py @@ -69,3 +69,40 @@ def test_show_plugin_wheels( """ # Check that it was constructed correctly assert response.content.decode() == html_page + + +@pytest.mark.parametrize("package_found", (True, False)) +def test_get_plugin_wheel( + mocker: MockerFixture, + package_found: bool, + tmp_path: Path, +): + # Set up test parameters + instrument_name = "murfey-test" + package_name = "package_a" + + # Create a test file + test_package = tmp_path / package_name + test_package.touch(exist_ok=True) + + # Mock the 'get_machine_config' return value + plugin_packages = {"package_a": test_package} + config = MachineConfig(plugin_packages=plugin_packages) + mock_get_machine_config = mocker.patch( + "murfey.server.api.bootstrap.get_machine_config", + return_value={instrument_name: config}, + ) + + # Set up the test backend client and the URL to poke + backend_server = set_up_test_backend_client() + backend_url_path = url_path_for( + "api.bootstrap.plugins", + "get_plugin_wheel", + instrument_name=instrument_name, + package="package_a" if package_found else "package_b", + ) + + # Poke it and check that the calls and response are as expected + response = backend_server.get(backend_url_path) + mock_get_machine_config.assert_called_once_with(instrument_name=instrument_name) + assert response.status_code == 200 if package_found else 404