From 11621ad6349171abc7640f99e0b37f001cbb43a8 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 12 Mar 2026 16:41:31 +0000 Subject: [PATCH 01/41] first crack at test --- src/sm_bluesky/common/server/__init__.py | 3 + .../server/abstract_instrument_server.py | 41 +++++++ tests/common/server/__init__.py | 0 .../server/test_abstract_instrument_server.py | 115 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 src/sm_bluesky/common/server/__init__.py create mode 100644 src/sm_bluesky/common/server/abstract_instrument_server.py create mode 100644 tests/common/server/__init__.py create mode 100644 tests/common/server/test_abstract_instrument_server.py diff --git a/src/sm_bluesky/common/server/__init__.py b/src/sm_bluesky/common/server/__init__.py new file mode 100644 index 00000000..fe40a135 --- /dev/null +++ b/src/sm_bluesky/common/server/__init__.py @@ -0,0 +1,3 @@ +from .abstract_instrument_server import AbstractInstrumentServer + +__all__ = ["AbstractInstrumentServer"] diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py new file mode 100644 index 00000000..0f132123 --- /dev/null +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -0,0 +1,41 @@ +from abc import abstractmethod +from socket import socket + + +class AbstractInstrumentServer: + def __init__(self, host: str, port: int): + self.host: str = host + self.port: int = port + self._is_running: bool = False + self._hardwarde_connected: bool = False + self._server_socket: socket + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + def _run_command_loop(self) -> None: + pass + + def _send_ack(self) -> None: + pass + + def _send_error(self, error_message: str) -> None: + pass + + def _send_response(self, response: str) -> None: + pass + + @abstractmethod + def connect_hardware(self) -> None: + raise NotImplementedError("Subclasses must implement connect_hardware") + + @abstractmethod + def disconnect_hardware(self) -> None: + raise NotImplementedError("Subclasses must implement disconnect_hardware") + + @abstractmethod + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + raise NotImplementedError("Subclasses must implement handle_command") diff --git a/tests/common/server/__init__.py b/tests/common/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py new file mode 100644 index 00000000..fe274811 --- /dev/null +++ b/tests/common/server/test_abstract_instrument_server.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from sm_bluesky.common.server import AbstractInstrumentServer + + +class MockInstrument(AbstractInstrumentServer): + def connect_hardware(self) -> None: + self._hardwarde_connected = True + + def disconnect_hardware(self) -> None: + self._hardwarde_connected = False + + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + if cmd == b"shutdown": + self._send_response("Shutting down server") + self.stop() + if cmd == b"ping": + self._send_ack() + if cmd == "disconnect": + self.disconnect_hardware() + + self._send_error("Unknown command") + + +@pytest.fixture +def mock_instrument(): + return MockInstrument(host="localhost", port=8888) + + +def test_connect_hardware(mock_instrument: AbstractInstrumentServer): + assert mock_instrument.connect_hardware() is True + + +def test_start_server_success( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + mock_instrument.start() + mock_instrument._run_command_loop = MagicMock() + assert mock_instrument._is_running is True + assert mock_instrument.connect_hardware() is True + assert "Hardware connected successfully" in caplog.text + assert ( + f"Server started on {mock_instrument.host}:{mock_instrument.port}" + in caplog.text + ) + assert mock_instrument._run_command_loop.assert_called_once() + + +def test_start_server_failure_hardware( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + # Simulate hardware connection failure by overriding the method + mock_instrument.connect_hardware = MagicMock( + side_effect=Exception("Simulated Hardware Failure") + ) + pytest.raises( + Exception, match="Simulated Hardware Failure", func=mock_instrument.start + ) + assert mock_instrument._is_running is False + + +@patch( + "sm_bluesky.common.server.abstract_instrument_server.AbstractInstrumentServer.socket.socket", + autospec=True, +) +def test_start_server_failure_socket( + mock_socket: MagicMock, + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, +): + + # Simulate socket failure by overriding the method + mock_socket.side_effect = Exception("Simulated Socket Failure") + mock_instrument.start() + assert mock_instrument._is_running is False + assert "Failed to start server" in caplog.text + assert "Simulated Socket Failure" in caplog.text + + +def test_stop_server( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + mock_instrument._server_socket = MagicMock() + mock_instrument.start() + assert mock_instrument._is_running is True + assert mock_instrument._server_socket is not None + assert mock_instrument._hardwarde_connected is True + mock_instrument._handle_command(b"shutdown", b"") + assert mock_instrument._hardwarde_connected is False + assert mock_instrument._is_running is False + assert "Server stopped" in caplog.text + + +def test_send_ack(mock_instrument: AbstractInstrumentServer): + mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._handle_command(b"ping", b"") + assert mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") + + +def test_send_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._handle_command(b"unknown", b"") + assert mock_instrument._server_socket.sendall.assert_called_once_with( + b"0\tUnknown command\n" + ) + + +def test_send_response(mock_instrument: AbstractInstrumentServer): + mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._handle_command(b"shutdown", b"") + assert mock_instrument._server_socket.sendall.assert_called_once_with( + b"1\tShutting down server\n" + ) From 6bcc51bb9e110d9f8bd7b057e571dfe7909bb9f0 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 12 Mar 2026 16:53:07 +0000 Subject: [PATCH 02/41] fixing typos --- .../common/server/abstract_instrument_server.py | 4 ++-- .../server/test_abstract_instrument_server.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 0f132123..f0d67337 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -7,7 +7,7 @@ def __init__(self, host: str, port: int): self.host: str = host self.port: int = port self._is_running: bool = False - self._hardwarde_connected: bool = False + self._hardware_connected: bool = False self._server_socket: socket def start(self) -> None: @@ -25,7 +25,7 @@ def _send_ack(self) -> None: def _send_error(self, error_message: str) -> None: pass - def _send_response(self, response: str) -> None: + def _send_response(self, response: str = "") -> None: pass @abstractmethod diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index fe274811..897aaea5 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -7,7 +7,7 @@ class MockInstrument(AbstractInstrumentServer): def connect_hardware(self) -> None: - self._hardwarde_connected = True + self._hardware_connected = True def disconnect_hardware(self) -> None: self._hardwarde_connected = False @@ -18,7 +18,7 @@ def _handle_command(self, cmd: bytes, arg: bytes) -> None: self.stop() if cmd == b"ping": self._send_ack() - if cmd == "disconnect": + if cmd == b"disconnect": self.disconnect_hardware() self._send_error("Unknown command") @@ -86,9 +86,9 @@ def test_stop_server( mock_instrument.start() assert mock_instrument._is_running is True assert mock_instrument._server_socket is not None - assert mock_instrument._hardwarde_connected is True + assert mock_instrument._hardware_connected is True mock_instrument._handle_command(b"shutdown", b"") - assert mock_instrument._hardwarde_connected is False + assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False assert "Server stopped" in caplog.text @@ -96,13 +96,13 @@ def test_stop_server( def test_send_ack(mock_instrument: AbstractInstrumentServer): mock_instrument._server_socket.sendall = MagicMock() mock_instrument._handle_command(b"ping", b"") - assert mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") + mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") def test_send_error(mock_instrument: AbstractInstrumentServer): mock_instrument._server_socket.sendall = MagicMock() mock_instrument._handle_command(b"unknown", b"") - assert mock_instrument._server_socket.sendall.assert_called_once_with( + mock_instrument._server_socket.sendall.assert_called_once_with( b"0\tUnknown command\n" ) @@ -110,6 +110,6 @@ def test_send_error(mock_instrument: AbstractInstrumentServer): def test_send_response(mock_instrument: AbstractInstrumentServer): mock_instrument._server_socket.sendall = MagicMock() mock_instrument._handle_command(b"shutdown", b"") - assert mock_instrument._server_socket.sendall.assert_called_once_with( + mock_instrument._server_socket.sendall.assert_called_once_with( b"1\tShutting down server\n" ) From 6d6d19db3c50a170576b14c164603a7ac22cd21c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 13:41:20 +0000 Subject: [PATCH 03/41] add start server --- .../server/abstract_instrument_server.py | 49 ++++++++++++-- .../server/test_abstract_instrument_server.py | 67 +++++++++++-------- 2 files changed, 84 insertions(+), 32 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index f0d67337..6e108c2e 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,8 @@ +import socket from abc import abstractmethod -from socket import socket +from contextlib import contextmanager + +from sm_bluesky.log import LOGGER class AbstractInstrumentServer: @@ -8,10 +11,48 @@ def __init__(self, host: str, port: int): self.port: int = port self._is_running: bool = False self._hardware_connected: bool = False - self._server_socket: socket + self._server_socket: socket.socket + self._conn: socket.socket | None = None def start(self) -> None: - pass + self._is_running = True + + self._hardware_connected = self.connect_hardware() + if not self._hardware_connected: + self._is_running = False + LOGGER.error("Failed to connect hardware") + raise RuntimeError("Failed to connect hardware") + LOGGER.info("Hardware connected successfully") + self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket.bind((self.host, self.port)) + self._server_socket.listen() + self._server_socket.settimeout(1.0) + self._is_running = True + + LOGGER.info(f"Server started listening on {self.host}:{self.port}") + + while self._is_running: + try: + client_info = self._server_socket.accept() + LOGGER.info(f"Connection accepted from {client_info}") + with self._manage_connection(client_info): + self._run_command_loop() + except TimeoutError: + continue + except Exception as e: + LOGGER.error(f"Error in server loop: {e}") + self._is_running = False + + @contextmanager + def _manage_connection(self, client_info: tuple[socket.socket, str]): + self._conn, addr = client_info + LOGGER.info(f"Client {addr} connected. Server busy.") + try: + yield + finally: + self._conn.close() + self._conn = None + LOGGER.info(f"Client {addr} disconnected. Server idle.") def stop(self) -> None: pass @@ -29,7 +70,7 @@ def _send_response(self, response: str = "") -> None: pass @abstractmethod - def connect_hardware(self) -> None: + def connect_hardware(self) -> bool: raise NotImplementedError("Subclasses must implement connect_hardware") @abstractmethod diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 897aaea5..3eeb43a7 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -6,11 +6,12 @@ class MockInstrument(AbstractInstrumentServer): - def connect_hardware(self) -> None: + def connect_hardware(self) -> bool: self._hardware_connected = True + return True def disconnect_hardware(self) -> None: - self._hardwarde_connected = False + self._hardware_connected = False def _handle_command(self, cmd: bytes, arg: bytes) -> None: if cmd == b"shutdown": @@ -30,53 +31,63 @@ def mock_instrument(): def test_connect_hardware(mock_instrument: AbstractInstrumentServer): - assert mock_instrument.connect_hardware() is True + assert mock_instrument._hardware_connected is False + mock_instrument.connect_hardware() + assert mock_instrument._hardware_connected is True +@patch("socket.socket") def test_start_server_success( - mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture + mock_socket_class: MagicMock, + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, ): - mock_instrument.start() - mock_instrument._run_command_loop = MagicMock() - assert mock_instrument._is_running is True - assert mock_instrument.connect_hardware() is True - assert "Hardware connected successfully" in caplog.text - assert ( - f"Server started on {mock_instrument.host}:{mock_instrument.port}" - in caplog.text + mock_server_socket = MagicMock() + mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() + mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + + mock_instrument._run_command_loop = lambda: setattr( + mock_instrument, "_is_running", False ) - assert mock_instrument._run_command_loop.assert_called_once() + mock_instrument.start() + + mock_server_socket.bind.assert_called_with(("localhost", 8888)) + assert "Server started listening on localhost:8888" in caplog.text + mock_server_socket.listen.assert_called_once() + mock_server_socket.accept.assert_called_once() + assert mock_instrument._is_running is False + assert "Connection accepted from" in caplog.text def test_start_server_failure_hardware( mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture ): # Simulate hardware connection failure by overriding the method - mock_instrument.connect_hardware = MagicMock( - side_effect=Exception("Simulated Hardware Failure") - ) - pytest.raises( - Exception, match="Simulated Hardware Failure", func=mock_instrument.start - ) + mock_instrument.connect_hardware = MagicMock(side_effect=[False]) + with pytest.raises(RuntimeError, match="Failed to connect hardware"): + mock_instrument.start() + assert "Failed to connect hardware" in caplog.text + assert mock_instrument._is_running is False -@patch( - "sm_bluesky.common.server.abstract_instrument_server.AbstractInstrumentServer.socket.socket", - autospec=True, -) -def test_start_server_failure_socket( +@patch("socket.socket") +def test_start_server_failure_on_accept( mock_socket: MagicMock, mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture, ): + error_message = "Simulated socket error" + mock_instance = MagicMock() + mock_socket.return_value = mock_instance - # Simulate socket failure by overriding the method - mock_socket.side_effect = Exception("Simulated Socket Failure") + mock_instance.accept.side_effect = Exception(error_message) mock_instrument.start() + assert mock_instrument._is_running is False - assert "Failed to start server" in caplog.text - assert "Simulated Socket Failure" in caplog.text + assert f"Error in server loop: {error_message}" in caplog.text + assert mock_instrument._conn is None def test_stop_server( From 4835373f76cc96165901fd098ae1250a9dc27375 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 15:18:30 +0000 Subject: [PATCH 04/41] added stop --- .../server/abstract_instrument_server.py | 42 +++++++++++++++---- .../server/test_abstract_instrument_server.py | 22 ++++++---- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 6e108c2e..d9a07566 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -50,24 +50,50 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): try: yield finally: - self._conn.close() - self._conn = None - LOGGER.info(f"Client {addr} disconnected. Server idle.") + self._disconnect_client() + LOGGER.info(f"Client {addr} disconnected. Server ready.") def stop(self) -> None: - pass + + self._disconnect_client() + if hasattr(self, "_server_socket"): + self._server_socket.close() + if self._hardware_connected: + self.disconnect_hardware() + self._hardware_connected = False + self._is_running = False + LOGGER.info("Server stopped successfully") + + def _disconnect_client(self) -> None: + if self._conn: + self._conn.close() + self._conn = None + LOGGER.info("Client disconnected") def _run_command_loop(self) -> None: - pass + if self._conn is None: + LOGGER.error("No client connection available to run command loop") + return + queued_data = self._conn.recv(1024).splitlines() + if not queued_data: + queued_data = [b"disconnect"] + for line in queued_data: + if b"\t" in line: + cmd, arg = line.split(b"\t", 1) + else: + cmd, arg = line, b"" + self._handle_command(cmd, arg) def _send_ack(self) -> None: - pass + self._send_response() def _send_error(self, error_message: str) -> None: - pass + if self._conn: + self._conn.sendall(b"0\t" + error_message.encode() + b"\n") def _send_response(self, response: str = "") -> None: - pass + if self._conn: + self._conn.sendall(b"1\t" + response.encode() + b"\n") @abstractmethod def connect_hardware(self) -> bool: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 3eeb43a7..ac6676f6 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -90,24 +90,30 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None +@patch("socket.socket", autospec=True) def test_stop_server( - mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture + mock_socket_class: MagicMock, + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, ): - mock_instrument._server_socket = MagicMock() + mock_server_socket = MagicMock() + mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() + mock_instrument._conn = mock_client_socket + mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t") mock_instrument.start() - assert mock_instrument._is_running is True - assert mock_instrument._server_socket is not None - assert mock_instrument._hardware_connected is True - mock_instrument._handle_command(b"shutdown", b"") + # mock_instrument._handle_command(b"shutdown", b"") assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False assert "Server stopped" in caplog.text +@patch("socket.socket") def test_send_ack(mock_instrument: AbstractInstrumentServer): - mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"ping", b"") - mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") + mock_instrument._conn.sendall.assert_called_once_with(b"1\n") def test_send_error(mock_instrument: AbstractInstrumentServer): From 0a416534d7ffb3385183ec6f69dae7ed6a6ca3a2 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 15:41:33 +0000 Subject: [PATCH 05/41] add responses --- .../server/test_abstract_instrument_server.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index ac6676f6..ffdf469d 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -17,12 +17,12 @@ def _handle_command(self, cmd: bytes, arg: bytes) -> None: if cmd == b"shutdown": self._send_response("Shutting down server") self.stop() - if cmd == b"ping": + elif cmd == b"ping": self._send_ack() - if cmd == b"disconnect": + elif cmd == b"disconnect": self.disconnect_hardware() - - self._send_error("Unknown command") + else: + self._send_error("Unknown command") @pytest.fixture @@ -103,30 +103,27 @@ def test_stop_server( mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t") mock_instrument.start() - # mock_instrument._handle_command(b"shutdown", b"") assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False assert "Server stopped" in caplog.text -@patch("socket.socket") def test_send_ack(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"ping", b"") - mock_instrument._conn.sendall.assert_called_once_with(b"1\n") + mock_instrument._conn.sendall.assert_called_once_with(b"1\t\n") -def test_send_error(mock_instrument: AbstractInstrumentServer): - mock_instrument._server_socket.sendall = MagicMock() - mock_instrument._handle_command(b"unknown", b"") - mock_instrument._server_socket.sendall.assert_called_once_with( - b"0\tUnknown command\n" - ) +def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + mock_instrument._handle_command(b"sdljkfnsdouifn", b"") + mock_instrument._conn.sendall.assert_called_once_with(b"0\tUnknown command\n") def test_send_response(mock_instrument: AbstractInstrumentServer): - mock_instrument._server_socket.sendall = MagicMock() - mock_instrument._handle_command(b"shutdown", b"") - mock_instrument._server_socket.sendall.assert_called_once_with( - b"1\tShutting down server\n" - ) + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + mock_instrument._send_response("data data data") + mock_instrument._conn.sendall.assert_called_once_with(b"1\tdata data data\n") From c5701cc87e1086600b597f44e77b1780fac1bc99 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:06:31 +0000 Subject: [PATCH 06/41] add test for timeout --- .../server/test_abstract_instrument_server.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index ffdf469d..a92bb017 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,3 +1,4 @@ +import socket from unittest.mock import MagicMock, patch import pytest @@ -60,6 +61,25 @@ def test_start_server_success( assert "Connection accepted from" in caplog.text +@patch("socket.socket") +def test_start_handles_timeout(mock_socket_class, mock_instrument): + mock_instance = MagicMock() + mock_socket_class.return_value = mock_instance + mock_instance.accept.side_effect = [ + socket.timeout, + (MagicMock(), ("127.0.0.1", 1234)), + ] + + with patch.object( + mock_instrument, + "_run_command_loop", + side_effect=lambda: setattr(mock_instrument, "_is_running", False), + ): + mock_instrument.start() + + assert mock_instance.accept.call_count == 2 + + def test_start_server_failure_hardware( mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture ): From bbe2f058cb78b6dbb0603089baa2fd97b372e4f4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:22:45 +0000 Subject: [PATCH 07/41] add _run_command_loop --- .../server/abstract_instrument_server.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index d9a07566..4944de8f 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -74,15 +74,34 @@ def _run_command_loop(self) -> None: if self._conn is None: LOGGER.error("No client connection available to run command loop") return - queued_data = self._conn.recv(1024).splitlines() - if not queued_data: - queued_data = [b"disconnect"] - for line in queued_data: - if b"\t" in line: - cmd, arg = line.split(b"\t", 1) - else: - cmd, arg = line, b"" + buffer = b"" + while self._is_running: + try: + chunk = self._conn.recv(1024) + if not chunk: + break + buffer += chunk + + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + if line: # Ignore empty lines + self._process_line(line) + + except (OSError, ConnectionResetError): + break + + def _process_line(self, line: bytes) -> None: + line = line.strip() + + if b"\t" in line: + cmd, arg = line.split(b"\t", 1) + else: + cmd, arg = line, b"" + + try: self._handle_command(cmd, arg) + except Exception as e: + self._send_error(str(e)) def _send_ack(self) -> None: self._send_response() @@ -96,13 +115,10 @@ def _send_response(self, response: str = "") -> None: self._conn.sendall(b"1\t" + response.encode() + b"\n") @abstractmethod - def connect_hardware(self) -> bool: - raise NotImplementedError("Subclasses must implement connect_hardware") + def connect_hardware(self) -> bool: ... @abstractmethod - def disconnect_hardware(self) -> None: - raise NotImplementedError("Subclasses must implement disconnect_hardware") + def disconnect_hardware(self) -> None: ... @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - raise NotImplementedError("Subclasses must implement handle_command") + def _handle_command(self, cmd: bytes, arg: bytes) -> None: ... From 429cd543874cf1f58da6a9fd655d240503140582 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:24:53 +0000 Subject: [PATCH 08/41] rename command loop to serve_client --- src/sm_bluesky/common/server/abstract_instrument_server.py | 4 ++-- tests/common/server/test_abstract_instrument_server.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 4944de8f..914d4e0e 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -36,7 +36,7 @@ def start(self) -> None: client_info = self._server_socket.accept() LOGGER.info(f"Connection accepted from {client_info}") with self._manage_connection(client_info): - self._run_command_loop() + self._serve_client() except TimeoutError: continue except Exception as e: @@ -70,7 +70,7 @@ def _disconnect_client(self) -> None: self._conn = None LOGGER.info("Client disconnected") - def _run_command_loop(self) -> None: + def _serve_client(self) -> None: if self._conn is None: LOGGER.error("No client connection available to run command loop") return diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index a92bb017..1ee6bf7e 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -48,7 +48,7 @@ def test_start_server_success( mock_client_socket = MagicMock() mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) - mock_instrument._run_command_loop = lambda: setattr( + mock_instrument._serve_client = lambda: setattr( mock_instrument, "_is_running", False ) mock_instrument.start() @@ -72,7 +72,7 @@ def test_start_handles_timeout(mock_socket_class, mock_instrument): with patch.object( mock_instrument, - "_run_command_loop", + "_serve_client", side_effect=lambda: setattr(mock_instrument, "_is_running", False), ): mock_instrument.start() From 061b29dc3fb465c044cb99ef8ff043f214b9917d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:45:38 +0000 Subject: [PATCH 09/41] rename process lline --- src/sm_bluesky/common/server/abstract_instrument_server.py | 5 ++--- tests/common/server/test_abstract_instrument_server.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 914d4e0e..9477321f 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -85,13 +85,12 @@ def _serve_client(self) -> None: while b"\n" in buffer: line, buffer = buffer.split(b"\n", 1) if line: # Ignore empty lines - self._process_line(line) + self._dispatch_command(line.strip()) except (OSError, ConnectionResetError): break - def _process_line(self, line: bytes) -> None: - line = line.strip() + def _dispatch_command(self, line: bytes) -> None: if b"\t" in line: cmd, arg = line.split(b"\t", 1) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 1ee6bf7e..18bfafb0 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -110,7 +110,7 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None -@patch("socket.socket", autospec=True) +@patch("socket.socket") def test_stop_server( mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, @@ -121,7 +121,7 @@ def test_stop_server( mock_client_socket = MagicMock() mock_instrument._conn = mock_client_socket mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) - mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t") + mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t\n") mock_instrument.start() assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False From 1676c97896a9e7121bd3ecd42275b960c43a6728 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 11:33:17 +0000 Subject: [PATCH 10/41] correct timeout and allow ipv6 --- src/sm_bluesky/common/server/abstract_instrument_server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 9477321f..67bd1f8b 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -6,13 +6,14 @@ class AbstractInstrumentServer: - def __init__(self, host: str, port: int): + def __init__(self, host: str, port: int, ipv6: bool = False): self.host: str = host self.port: int = port self._is_running: bool = False self._hardware_connected: bool = False self._server_socket: socket.socket self._conn: socket.socket | None = None + self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET def start(self) -> None: self._is_running = True @@ -23,7 +24,7 @@ def start(self) -> None: LOGGER.error("Failed to connect hardware") raise RuntimeError("Failed to connect hardware") LOGGER.info("Hardware connected successfully") - self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket = socket.socket(self.address_type, socket.SOCK_STREAM) self._server_socket.bind((self.host, self.port)) self._server_socket.listen() self._server_socket.settimeout(1.0) @@ -37,7 +38,7 @@ def start(self) -> None: LOGGER.info(f"Connection accepted from {client_info}") with self._manage_connection(client_info): self._serve_client() - except TimeoutError: + except socket.timeout: # noqa: UP041 continue except Exception as e: LOGGER.error(f"Error in server loop: {e}") From 9821bca59526f2221c4ab7c787e61aa4daf5033b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 13:37:40 +0000 Subject: [PATCH 11/41] added error testing and full cycle test --- .../server/abstract_instrument_server.py | 3 +- .../server/test_abstract_instrument_server.py | 70 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 67bd1f8b..3b80fe22 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -85,10 +85,11 @@ def _serve_client(self) -> None: while b"\n" in buffer: line, buffer = buffer.split(b"\n", 1) - if line: # Ignore empty lines + if line: self._dispatch_command(line.strip()) except (OSError, ConnectionResetError): + LOGGER.error("Client connection lost unexpectedly") break def _dispatch_command(self, line: bytes) -> None: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 18bfafb0..097a2c0b 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -67,7 +67,7 @@ def test_start_handles_timeout(mock_socket_class, mock_instrument): mock_socket_class.return_value = mock_instance mock_instance.accept.side_effect = [ socket.timeout, - (MagicMock(), ("127.0.0.1", 1234)), + (MagicMock(), ("8.8.8.8", 1234)), ] with patch.object( @@ -147,3 +147,71 @@ def test_send_response(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._send_response("data data data") mock_instrument._conn.sendall.assert_called_once_with(b"1\tdata data data\n") + + +def test_serve_client_eof( + mock_instrument: AbstractInstrumentServer, +): + mock_conn = MagicMock() + mock_instrument._conn = mock_conn + mock_conn.recv.side_effect = [b"", b"shutdown\t\n"] + mock_instrument._is_running = True + mock_instrument._serve_client() + mock_instrument._conn.recv.assert_called_once() + + +def test_serve_client_no_client_connected( + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, +): + mock_instrument._conn = None + mock_instrument._serve_client() + assert "No client connection available to run command loop" in caplog.text + + +def test_serve_client_exception( + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, +): + mock_conn = MagicMock() + mock_instrument._conn = mock_conn + mock_conn.recv.side_effect = [ + OSError("Simulated connection error"), + b"shutdown\t\n", + ] + mock_instrument._is_running = True + mock_instrument._serve_client() + mock_instrument._conn.recv.assert_called_once() + assert "Client connection lost unexpectedly" in caplog.text + + +def test_full_connection_cycle_cleanup(mock_instrument, caplog): + mock_instance = MagicMock() + mock_instance.accept.return_value = (MagicMock(), ("8.8.8.8", 1234)) + mock_instrument._server_socket = mock_instance + + with patch.object(mock_instrument, "_serve_client", side_effect=None): + client_info = (MagicMock(), "8.8.8.8") + with mock_instrument._manage_connection(client_info): # + pass + assert mock_instrument._conn is None + assert mock_instance.close.called_once() + assert "Client disconnected" in caplog.text + + +def test_dispatch_command_exception_handling( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + mock_instrument._dispatch_command(b"test exception") + mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) + mock_instrument._send_error = MagicMock() + mock_instrument._dispatch_command(b"test exception") + mock_instrument._send_error.assert_called_once_with("Test exception") + + +def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): + mock_instrument._handle_command = MagicMock() + mock_instrument._dispatch_command(b"command\targument\targument2") + mock_instrument._handle_command.assert_called_once_with( + b"command", b"argument\targument2" + ) From 51c2db4b871b609e5177c77a13848434b9ebec9c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 13:54:41 +0000 Subject: [PATCH 12/41] Add minimal docstring --- .../server/abstract_instrument_server.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 3b80fe22..9d342c70 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -6,6 +6,13 @@ class AbstractInstrumentServer: + """ + Base class for TCP instrument servers. + + Handles socket lifecycle, connection management, and buffered command + parsing. Subclasses must implement hardware-specific control logic. + """ + def __init__(self, host: str, port: int, ipv6: bool = False): self.host: str = host self.port: int = port @@ -16,6 +23,7 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET def start(self) -> None: + """Initializes the server, connects hardware, and enters the listening loop.""" self._is_running = True self._hardware_connected = self.connect_hardware() @@ -46,6 +54,7 @@ def start(self) -> None: @contextmanager def _manage_connection(self, client_info: tuple[socket.socket, str]): + """Manages the lifecycle of a client connection with automatic cleanup.""" self._conn, addr = client_info LOGGER.info(f"Client {addr} connected. Server busy.") try: @@ -55,7 +64,7 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): LOGGER.info(f"Client {addr} disconnected. Server ready.") def stop(self) -> None: - + """Stops the server, closes sockets, and disconnects hardware.""" self._disconnect_client() if hasattr(self, "_server_socket"): self._server_socket.close() @@ -72,6 +81,7 @@ def _disconnect_client(self) -> None: LOGGER.info("Client disconnected") def _serve_client(self) -> None: + """Reads stream data from the client and handles command buffering.""" if self._conn is None: LOGGER.error("No client connection available to run command loop") return @@ -93,7 +103,7 @@ def _serve_client(self) -> None: break def _dispatch_command(self, line: bytes) -> None: - + """Parses raw input into command/argument pairs and executes the handler.""" if b"\t" in line: cmd, arg = line.split(b"\t", 1) else: @@ -116,10 +126,13 @@ def _send_response(self, response: str = "") -> None: self._conn.sendall(b"1\t" + response.encode() + b"\n") @abstractmethod - def connect_hardware(self) -> bool: ... + def connect_hardware(self) -> bool: + """Establishes connection to the specific hardware device.""" @abstractmethod - def disconnect_hardware(self) -> None: ... + def disconnect_hardware(self) -> None: + """Disconnect from the hardware device.""" @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: ... + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + """Executes logic for a specific instrument command.""" From 4fb19a05b7c76f3ec4c14fdb5ffd568d67bbc47e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 13:56:38 +0000 Subject: [PATCH 13/41] add abc to class --- src/sm_bluesky/common/server/abstract_instrument_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 9d342c70..8f8d95b4 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,11 +1,11 @@ import socket -from abc import abstractmethod +from abc import ABC, abstractmethod from contextlib import contextmanager from sm_bluesky.log import LOGGER -class AbstractInstrumentServer: +class AbstractInstrumentServer(ABC): """ Base class for TCP instrument servers. From 1e582d7b7fd4e5f749b74fc1c93d05096cf70547 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 14:11:57 +0000 Subject: [PATCH 14/41] correct test --- tests/common/server/test_abstract_instrument_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 097a2c0b..edeb2da5 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -192,10 +192,10 @@ def test_full_connection_cycle_cleanup(mock_instrument, caplog): with patch.object(mock_instrument, "_serve_client", side_effect=None): client_info = (MagicMock(), "8.8.8.8") - with mock_instrument._manage_connection(client_info): # + with mock_instrument._manage_connection(client_info): pass + client_info[0].close.assert_called_once() assert mock_instrument._conn is None - assert mock_instance.close.called_once() assert "Client disconnected" in caplog.text From 4dfc68afbbdcfef29dad628ed00b6bd2117b6c5a Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 15:32:24 +0000 Subject: [PATCH 15/41] remove redundancy --- tests/common/server/test_abstract_instrument_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index edeb2da5..4b89b187 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -202,7 +202,6 @@ def test_full_connection_cycle_cleanup(mock_instrument, caplog): def test_dispatch_command_exception_handling( mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture ): - mock_instrument._dispatch_command(b"test exception") mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) mock_instrument._send_error = MagicMock() mock_instrument._dispatch_command(b"test exception") From 3f18d4a3c9378ab6d8ac301918406d362c4c1add Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 12:12:55 +0000 Subject: [PATCH 16/41] add command_registry for Abstract class commands. --- .../server/abstract_instrument_server.py | 25 +++++++++++++--- .../server/test_abstract_instrument_server.py | 29 +++++++++++-------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 8f8d95b4..b9630dd9 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,6 @@ import socket from abc import ABC, abstractmethod +from collections.abc import Callable from contextlib import contextmanager from sm_bluesky.log import LOGGER @@ -21,6 +22,12 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self._server_socket: socket.socket self._conn: socket.socket | None = None self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET + self._command_registry: dict[bytes, Callable] = { + b"connect_hardware": self.connect_hardware, + b"disconnect_hardware": self.disconnect_hardware, + b"ping": self._send_ack, + b"shutdown": self.stop, + } def start(self) -> None: """Initializes the server, connects hardware, and enters the listening loop.""" @@ -125,6 +132,20 @@ def _send_response(self, response: str = "") -> None: if self._conn: self._conn.sendall(b"1\t" + response.encode() + b"\n") + def _handle_command(self, cmd: bytes, args: bytes) -> None: + """Executes logic for a specific instrument command.""" + handler = self._command_registry.get(cmd) + if not handler: + LOGGER.warning(f"Received unknown command: {cmd.decode()}") + self._send_error("Received unknown command") + else: + try: + handler(args) if args else handler() + + except Exception as e: + LOGGER.error(f"Error handling command '{cmd.decode()}': {e}") + self._send_error(f"Error handling command '{cmd.decode()}': {e}") + @abstractmethod def connect_hardware(self) -> bool: """Establishes connection to the specific hardware device.""" @@ -132,7 +153,3 @@ def connect_hardware(self) -> bool: @abstractmethod def disconnect_hardware(self) -> None: """Disconnect from the hardware device.""" - - @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - """Executes logic for a specific instrument command.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 4b89b187..2cdf311f 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -14,17 +14,6 @@ def connect_hardware(self) -> bool: def disconnect_hardware(self) -> None: self._hardware_connected = False - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - if cmd == b"shutdown": - self._send_response("Shutting down server") - self.stop() - elif cmd == b"ping": - self._send_ack() - elif cmd == b"disconnect": - self.disconnect_hardware() - else: - self._send_error("Unknown command") - @pytest.fixture def mock_instrument(): @@ -139,7 +128,23 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") - mock_instrument._conn.sendall.assert_called_once_with(b"0\tUnknown command\n") + mock_instrument._conn.sendall.assert_called_once_with( + b"0\tReceived unknown command\n" + ) + + +def test_send_command_handling_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + + def handling_exception(): + raise Exception(Exception("test_send_command_handling_error")) + + mock_instrument._command_registry.update({b"ping": handling_exception}) + mock_instrument._handle_command(b"ping", b"") + mock_instrument._conn.sendall.assert_called_once_with( + b"0\tError handling command 'ping': test_send_command_handling_error\n" + ) def test_send_response(mock_instrument: AbstractInstrumentServer): From f94cfaf955c6a9de1b3fa8d6d391c04a9753a2e4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 13:25:39 +0000 Subject: [PATCH 17/41] add helper for error handling --- .../server/abstract_instrument_server.py | 26 ++++++++++++++----- .../server/test_abstract_instrument_server.py | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index b9630dd9..6e60576c 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -3,7 +3,7 @@ from collections.abc import Callable from contextlib import contextmanager -from sm_bluesky.log import LOGGER +from sm_bluesky.log import LOGGER, logging class AbstractInstrumentServer(ABC): @@ -119,7 +119,7 @@ def _dispatch_command(self, line: bytes) -> None: try: self._handle_command(cmd, arg) except Exception as e: - self._send_error(str(e)) + self._error_helper(message="Handler Error", error=e) def _send_ack(self) -> None: self._send_response() @@ -136,15 +136,29 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: """Executes logic for a specific instrument command.""" handler = self._command_registry.get(cmd) if not handler: - LOGGER.warning(f"Received unknown command: {cmd.decode()}") - self._send_error("Received unknown command") + self._error_helper( + message=f"Received unknown command: '{cmd.decode()}'", + error=Exception("Unknow command"), + level=logging.WARNING, + ) else: try: handler(args) if args else handler() except Exception as e: - LOGGER.error(f"Error handling command '{cmd.decode()}': {e}") - self._send_error(f"Error handling command '{cmd.decode()}': {e}") + self._error_helper( + message=f"Error handling command '{cmd.decode()}'", error=e + ) + + def _error_helper( + self, + message: str, + error: Exception | None = None, + level: int = logging.ERROR, + ): + err_msg = f"{message}: {error}" if error else message + LOGGER.log(level=level, msg=err_msg) + self._send_error(err_msg) @abstractmethod def connect_hardware(self) -> bool: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 2cdf311f..03b350ce 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -129,7 +129,7 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") mock_instrument._conn.sendall.assert_called_once_with( - b"0\tReceived unknown command\n" + b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknow command\n" ) @@ -210,7 +210,7 @@ def test_dispatch_command_exception_handling( mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) mock_instrument._send_error = MagicMock() mock_instrument._dispatch_command(b"test exception") - mock_instrument._send_error.assert_called_once_with("Test exception") + mock_instrument._send_error.assert_called_once_with("Handler Error: Test exception") def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): From e1446f932f6667f74308060a16c99fbbff9e823b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 16:59:03 +0000 Subject: [PATCH 18/41] clean up test --- .../server/test_abstract_instrument_server.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 03b350ce..7dfd9966 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -16,8 +16,19 @@ def disconnect_hardware(self) -> None: @pytest.fixture -def mock_instrument(): - return MockInstrument(host="localhost", port=8888) +def mock_socket_instance(): + return MagicMock(spec=socket.socket) + + +@pytest.fixture +def mock_instrument(mock_socket_instance: MagicMock): + + with patch( + "sm_bluesky.common.server.abstract_instrument_server.socket.socket" + ) as mock_socket_class: + mock_socket_class.return_value = mock_socket_instance + mock_instrument = MockInstrument(host="localhost", port=8888) + yield mock_instrument def test_connect_hardware(mock_instrument: AbstractInstrumentServer): @@ -26,47 +37,41 @@ def test_connect_hardware(mock_instrument: AbstractInstrumentServer): assert mock_instrument._hardware_connected is True -@patch("socket.socket") def test_start_server_success( - mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): - mock_server_socket = MagicMock() - mock_socket_class.return_value = mock_server_socket mock_client_socket = MagicMock() - mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_socket_instance.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._serve_client = lambda: setattr( mock_instrument, "_is_running", False ) mock_instrument.start() - mock_server_socket.bind.assert_called_with(("localhost", 8888)) + mock_socket_instance.bind.assert_called_with(("localhost", 8888)) assert "Server started listening on localhost:8888" in caplog.text - mock_server_socket.listen.assert_called_once() - mock_server_socket.accept.assert_called_once() + mock_socket_instance.listen.assert_called_once() + mock_socket_instance.accept.assert_called_once() assert mock_instrument._is_running is False assert "Connection accepted from" in caplog.text -@patch("socket.socket") -def test_start_handles_timeout(mock_socket_class, mock_instrument): - mock_instance = MagicMock() - mock_socket_class.return_value = mock_instance - mock_instance.accept.side_effect = [ +def test_start_handles_timeout( + mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, +): + mock_socket_instance.accept.side_effect = [ socket.timeout, (MagicMock(), ("8.8.8.8", 1234)), ] + mock_instrument._serve_client = lambda: setattr( + mock_instrument, "_is_running", False + ) + mock_instrument.start() - with patch.object( - mock_instrument, - "_serve_client", - side_effect=lambda: setattr(mock_instrument, "_is_running", False), - ): - mock_instrument.start() - - assert mock_instance.accept.call_count == 2 + assert mock_socket_instance.accept.call_count == 2 def test_start_server_failure_hardware( @@ -81,17 +86,14 @@ def test_start_server_failure_hardware( assert mock_instrument._is_running is False -@patch("socket.socket") def test_start_server_failure_on_accept( - mock_socket: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): error_message = "Simulated socket error" - mock_instance = MagicMock() - mock_socket.return_value = mock_instance - mock_instance.accept.side_effect = Exception(error_message) + mock_socket_instance.accept.side_effect = Exception(error_message) mock_instrument.start() assert mock_instrument._is_running is False @@ -99,17 +101,15 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None -@patch("socket.socket") def test_stop_server( - mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): - mock_server_socket = MagicMock() - mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() mock_instrument._conn = mock_client_socket - mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_socket_instance.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t\n") mock_instrument.start() assert mock_instrument._hardware_connected is False From 514098d4b564f6a8e8f72bfdb9f66b3312260d5e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 13:54:15 +0000 Subject: [PATCH 19/41] change response to take byes --- src/sm_bluesky/common/server/abstract_instrument_server.py | 4 ++-- tests/common/server/test_abstract_instrument_server.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 6e60576c..2a0c7911 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -128,9 +128,9 @@ def _send_error(self, error_message: str) -> None: if self._conn: self._conn.sendall(b"0\t" + error_message.encode() + b"\n") - def _send_response(self, response: str = "") -> None: + def _send_response(self, response: bytes = b"") -> None: if self._conn: - self._conn.sendall(b"1\t" + response.encode() + b"\n") + self._conn.sendall(b"1\t" + response + b"\n") def _handle_command(self, cmd: bytes, args: bytes) -> None: """Executes logic for a specific instrument command.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 7dfd9966..862477b8 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -150,7 +150,7 @@ def handling_exception(): def test_send_response(mock_instrument: AbstractInstrumentServer): mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() - mock_instrument._send_response("data data data") + mock_instrument._send_response(b"data data data") mock_instrument._conn.sendall.assert_called_once_with(b"1\tdata data data\n") From 91a0d6c89667b57a38a5dfc50448567db8550353 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 23 Mar 2026 12:44:23 +0000 Subject: [PATCH 20/41] just function prototype --- pyproject.toml | 4 + src/sm_bluesky/common/server/HF2Sever.py | 107 +++++++++++++++++++++++ uv.lock | 53 ++++++++++- 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/sm_bluesky/common/server/HF2Sever.py diff --git a/pyproject.toml b/pyproject.toml index bc90c1d3..d2d8c9a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,12 @@ license.file = "LICENSE" readme = "README.md" requires-python = ">=3.11" + +[project.optional-dependencies] +server = ["pyserial", "zhinst-core"] [dependency-groups] dev = [ + "sm_bluesky[server]", "copier", "myst-parser", "pre-commit", diff --git a/src/sm_bluesky/common/server/HF2Sever.py b/src/sm_bluesky/common/server/HF2Sever.py new file mode 100644 index 00000000..eda033ed --- /dev/null +++ b/src/sm_bluesky/common/server/HF2Sever.py @@ -0,0 +1,107 @@ +import logging +from time import sleep, time +from typing import Literal + +import numpy as np +import zhinst.core + +from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.log import LOGGER + + +class HF2Server(AbstractInstrumentServer): + api_level: Literal[0, 1, 4, 5, 6] + + def __init__( + self, + host: str = "", + port: int = 7891, + hf2_ip: str = "172.23.110.84", + hf2_port: int = 8004, + api_level: Literal[0, 1, 4, 5, 6] = 6, + device_id: str = "dev4206", + ): + super().__init__(host, port) + self.hf2_ip = hf2_ip + self.hf2_port = hf2_port + self.api_level = api_level + self.device_id = device_id + + self.daq: zhinst.core.ziDAQServer | None = None + self.scope: zhinst.core.ScopeModule | None = None + + # Register HF2 specific commands + self._command_registry.update( + { + b"getData": self._get_combined_data, + b"autoVoltageInRange": self._auto_voltage_range, + b"setTimeConstant": self._set_time_constant, + b"setDataRate": self._set_data_rate, + b"setCurrentInRange": self._set_current_range, + b"autoCurrentInRange": self._auto_current_range, + b"setRefFreq": self._set_ref_freq, + b"setRefV": self._set_ref_vpk, + b"setRefVoff": self._set_ref_voff, + b"setsRefOutSwitch": self._set_ref_output, + b"setsRefHarm": self._set_ref_harmonic, + b"setupScope": self._setup_scope_cmd, + } + ) + + def connect_hardware(self) -> bool: + """Connect to Zurich Instruments HF2 Data Server.""" + return False + + def disconnect_hardware(self) -> None: + """Disconnect from HF2 and cleanup modules.""" + + + # --- Hardware Logic Methods --- + + def _setup_scope(self, freq: float = 5.0, length: int = 4096, channel: int = 0): + + + def _get_single_scope_shot(self) -> float: + """Returns the mean value of a single scope shot.""" + + + def _get_lockin_data(self, duration: float) -> Tuple[float, float, float, float]: + """Averages demodulator data over a specific duration.""" + + + + # --- Command Handlers --- + + def _get_combined_data(self, value: bytes = b"0.1"): + """Handles 'getData' command.""" + + + def _set_ref_freq(self, value: bytes): + + + def _set_current_range(self, value: bytes): + + + def _set_ref_output(self, value: bytes): + + + def _setup_scope_cmd(self, args: bytes): + """Expects: 'freq\tlength\tchannel'""" + + # Add other simple mappings... + def _auto_voltage_range(self): + + + def _set_time_constant(self, val: bytes): + + def _set_data_rate(self, val: bytes): + + + def _auto_current_range(self): + + def _set_ref_vpk(self, val: bytes): + + def _set_ref_voff(self, val: bytes): + + + def _set_ref_harmonic(self, val: bytes): diff --git a/uv.lock b/uv.lock index 89a27f5f..963fafba 100644 --- a/uv.lock +++ b/uv.lock @@ -4007,7 +4007,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4695,6 +4695,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -5446,6 +5455,12 @@ dependencies = [ { name = "scanspec" }, ] +[package.optional-dependencies] +server = [ + { name = "pyserial" }, + { name = "zhinst-core" }, +] + [package.dev-dependencies] dev = [ { name = "blueapi" }, @@ -5465,6 +5480,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sm-bluesky", extra = ["server"] }, { name = "sphinx-autobuild" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -5477,8 +5493,11 @@ requires-dist = [ { name = "bluesky" }, { name = "dls-dodal", specifier = ">=2.1.0" }, { name = "ophyd-async", extras = ["sim"] }, + { name = "pyserial", marker = "extra == 'server'" }, { name = "scanspec" }, + { name = "zhinst-core", marker = "extra == 'server'" }, ] +provides-extras = ["server"] [package.metadata.requires-dev] dev = [ @@ -5498,6 +5517,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sm-bluesky", extras = ["server"] }, { name = "sphinx-autobuild" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -6618,6 +6638,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] +[[package]] +name = "zhinst-core" +version = "26.1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/a0/d23c1ba7240aece3fd5d00a5386f0220dd2320b0f4f30a9814d0cfa0842e/zhinst_core-26.1.2.4-cp311-cp311-macosx_10_11_x86_64.whl", hash = "sha256:f2b463104b65c3382a77b1ec6378e07ae01f39e3221850cf9df85732e2c6ff58", size = 10505824, upload-time = "2026-03-03T07:54:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f0/4bf521d5003fdf13e65ff7999084707cb047458f34bee0648215ca0d3564/zhinst_core-26.1.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a4f3bc0859e1fcbb905759d05a44d229338c0911a16e7f0ce8a8355c59bebfc1", size = 9688448, upload-time = "2026-03-03T07:54:18.197Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fe/9e313156bdb88b869a084c0c5139320b1657107e83e198510ac9a2e6c3f2/zhinst_core-26.1.2.4-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:5a193ad67b2e682bd51a04c190fdc77fd745fe3482e935af600c71124d57a2d8", size = 13261368, upload-time = "2026-03-03T07:54:22.021Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/af8a4aed81387833d982297e5390ab5db407bb5ea60d697aefe89c734bdd/zhinst_core-26.1.2.4-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:1e0d534cd72904b3d334a26951a9be60ab0948733718bef81292e74c18d4084c", size = 13143218, upload-time = "2026-03-03T07:54:26.13Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/1fae1093bff1d60f12c921e96ffa1000524b4cfff7815be2a78a2bb4100d/zhinst_core-26.1.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:ed0691a11828fbfd496807867cc099288e42b6da342f065c0cf6aa4f57a8f643", size = 9311186, upload-time = "2026-03-03T07:54:29.661Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f04710fd5526565c9d8595faf874ec7c3229e590c5734451fb9898efcbbf/zhinst_core-26.1.2.4-cp312-cp312-macosx_10_11_x86_64.whl", hash = "sha256:9c4db7a1f2b0f886f089e7e8798caf6bf16f26c6aad0df5bd18cc1d1c945c0ff", size = 10516764, upload-time = "2026-03-03T07:54:33.38Z" }, + { url = "https://files.pythonhosted.org/packages/c8/58/8796d49324f049c24824de38a16baac81d82cb9f19748f43521cd1128355/zhinst_core-26.1.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7e84b21075f4d262e14a77b95714f8dd130bc06df8d1be6891d22467491e8c13", size = 9692958, upload-time = "2026-03-03T07:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/75/8d/78aa1506f6f00df78c78a4dd8a3cd899497976720122292f064090f58bad/zhinst_core-26.1.2.4-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:f567345ec82b9d15daedfe72acd32b58a4617b8ad724e32b33d30937e16a4259", size = 13269276, upload-time = "2026-03-03T07:54:41.439Z" }, + { url = "https://files.pythonhosted.org/packages/6d/75/14b4a44b56e2bd2b0993ca7acce06231edf20a619a978ea51131f9427c60/zhinst_core-26.1.2.4-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:fc2d9064654e1f3cc5431455c9762008613011b048e5bd12665e83155e29941f", size = 13146601, upload-time = "2026-03-03T07:54:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/95d46c5c6f617e11119b2c3f50d2661024e319fb2fbc98d2b20067da168f/zhinst_core-26.1.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:fa1f3e0a2b7e95a47965de392d082366c4b70651218a1fc27c7e93b84231c52a", size = 9318142, upload-time = "2026-03-03T07:54:50.615Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/bcd42e05686e504c1900640f5b665a0b29a47c1c93c9c1bdde998e6ef6ac/zhinst_core-26.1.2.4-cp313-cp313-macosx_10_11_x86_64.whl", hash = "sha256:f806272ef68e8bdeae5c98e3b9c49966fcd8b12cc527376f01816c043cdf670d", size = 10516690, upload-time = "2026-03-03T07:54:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/32/3c/d64493b076a83b4ce744a18f0ef326bc869c2908c64ad1b6f28661299536/zhinst_core-26.1.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a663b98d1ec51e255dd445fb6a4cfd181dd7916e09feecbf0abce1bfe794e3", size = 9693055, upload-time = "2026-03-03T07:54:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/683e3a8a3995218f34dff9e3780e0b877400166f119ac9c7b2f9e556cd9a/zhinst_core-26.1.2.4-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:d8175284d358cc9ef476c1c27ddf47d32d2676332520b96f43b33ebfcf9a6f55", size = 13269376, upload-time = "2026-03-03T07:55:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/9c/69/d3e75953c52359a5f7c30f912768578945b3709cdecb5d26ec42e44b0bec/zhinst_core-26.1.2.4-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:866c317e2458de000b5feb2ebe30620c0277d4aa8de0bd3bf7f392446184ea11", size = 13145788, upload-time = "2026-03-03T07:55:06.052Z" }, + { url = "https://files.pythonhosted.org/packages/19/fd/3b91854ca91ce1654a89fc654cdd6e1e6d4ce224b550c82ac3b369e4c7f9/zhinst_core-26.1.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:6a6c94eaaf430fe65dcce9cc08d8c6cfea643fd455c6c03f3d28253c1f160e1e", size = 9317952, upload-time = "2026-03-03T07:55:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/e4a4f9774ad46505c7f8d0e4473458480d69373011e870f0759b6b9f7a9a/zhinst_core-26.1.2.4-cp314-cp314-macosx_10_11_x86_64.whl", hash = "sha256:dd465265e80f0fca57f69a61ee4471ac121076f5f6a7bc77888fd0975e7a96d1", size = 10512542, upload-time = "2026-03-03T07:55:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/83/f6fb061ee9891e638456f38a133f2e8ec760f39d96555a40870ed9acb33c/zhinst_core-26.1.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cee2adef326a417deb1454633e5355c654576d5e14393bfa7cdba657e86e46c8", size = 9689689, upload-time = "2026-03-03T07:55:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6e/e4e23191cb286e625caffc7578557f4d0bed6fad6a91a20cee690519b9c9/zhinst_core-26.1.2.4-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:28ea1af131eed70a19b27ad3148b6971c1714a94cea3ec1fa8de102c938e9586", size = 13268592, upload-time = "2026-03-03T07:55:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/4c526afb7d7cd5d6b90f2dcd44e1f84c071f5d7ed13a6adaa2e97a427a9d/zhinst_core-26.1.2.4-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:0622fc2a58cb799293dda6f0af81efb75e55088734bc3ab3e3f2d53a9813213e", size = 13148825, upload-time = "2026-03-03T07:55:23.294Z" }, + { url = "https://files.pythonhosted.org/packages/59/85/0b9b602936826db4c6317fc98a3bb7761386dff98568f95e39316656cc71/zhinst_core-26.1.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:38dc56b9245391a90af3cb908372c862868f6922b7a5654014074afe30a67d88", size = 9318497, upload-time = "2026-03-03T07:55:27.712Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From 36cad88e78d358d6f083f263460853dcd0e8469f Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 23 Mar 2026 15:26:57 +0000 Subject: [PATCH 21/41] adding tests --- src/sm_bluesky/common/server/HF2Sever.py | 107 ----------------------- src/sm_bluesky/common/server/__init__.py | 3 +- tests/common/server/test_hf2_server.py | 73 ++++++++++++++++ 3 files changed, 75 insertions(+), 108 deletions(-) delete mode 100644 src/sm_bluesky/common/server/HF2Sever.py create mode 100644 tests/common/server/test_hf2_server.py diff --git a/src/sm_bluesky/common/server/HF2Sever.py b/src/sm_bluesky/common/server/HF2Sever.py deleted file mode 100644 index eda033ed..00000000 --- a/src/sm_bluesky/common/server/HF2Sever.py +++ /dev/null @@ -1,107 +0,0 @@ -import logging -from time import sleep, time -from typing import Literal - -import numpy as np -import zhinst.core - -from sm_bluesky.common.server import AbstractInstrumentServer -from sm_bluesky.log import LOGGER - - -class HF2Server(AbstractInstrumentServer): - api_level: Literal[0, 1, 4, 5, 6] - - def __init__( - self, - host: str = "", - port: int = 7891, - hf2_ip: str = "172.23.110.84", - hf2_port: int = 8004, - api_level: Literal[0, 1, 4, 5, 6] = 6, - device_id: str = "dev4206", - ): - super().__init__(host, port) - self.hf2_ip = hf2_ip - self.hf2_port = hf2_port - self.api_level = api_level - self.device_id = device_id - - self.daq: zhinst.core.ziDAQServer | None = None - self.scope: zhinst.core.ScopeModule | None = None - - # Register HF2 specific commands - self._command_registry.update( - { - b"getData": self._get_combined_data, - b"autoVoltageInRange": self._auto_voltage_range, - b"setTimeConstant": self._set_time_constant, - b"setDataRate": self._set_data_rate, - b"setCurrentInRange": self._set_current_range, - b"autoCurrentInRange": self._auto_current_range, - b"setRefFreq": self._set_ref_freq, - b"setRefV": self._set_ref_vpk, - b"setRefVoff": self._set_ref_voff, - b"setsRefOutSwitch": self._set_ref_output, - b"setsRefHarm": self._set_ref_harmonic, - b"setupScope": self._setup_scope_cmd, - } - ) - - def connect_hardware(self) -> bool: - """Connect to Zurich Instruments HF2 Data Server.""" - return False - - def disconnect_hardware(self) -> None: - """Disconnect from HF2 and cleanup modules.""" - - - # --- Hardware Logic Methods --- - - def _setup_scope(self, freq: float = 5.0, length: int = 4096, channel: int = 0): - - - def _get_single_scope_shot(self) -> float: - """Returns the mean value of a single scope shot.""" - - - def _get_lockin_data(self, duration: float) -> Tuple[float, float, float, float]: - """Averages demodulator data over a specific duration.""" - - - - # --- Command Handlers --- - - def _get_combined_data(self, value: bytes = b"0.1"): - """Handles 'getData' command.""" - - - def _set_ref_freq(self, value: bytes): - - - def _set_current_range(self, value: bytes): - - - def _set_ref_output(self, value: bytes): - - - def _setup_scope_cmd(self, args: bytes): - """Expects: 'freq\tlength\tchannel'""" - - # Add other simple mappings... - def _auto_voltage_range(self): - - - def _set_time_constant(self, val: bytes): - - def _set_data_rate(self, val: bytes): - - - def _auto_current_range(self): - - def _set_ref_vpk(self, val: bytes): - - def _set_ref_voff(self, val: bytes): - - - def _set_ref_harmonic(self, val: bytes): diff --git a/src/sm_bluesky/common/server/__init__.py b/src/sm_bluesky/common/server/__init__.py index fe40a135..fc5b77ca 100644 --- a/src/sm_bluesky/common/server/__init__.py +++ b/src/sm_bluesky/common/server/__init__.py @@ -1,3 +1,4 @@ from .abstract_instrument_server import AbstractInstrumentServer +from .zurich_lockin_amplifier import HF2Server -__all__ = ["AbstractInstrumentServer"] +__all__ = ["AbstractInstrumentServer", "HF2Server"] diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_hf2_server.py new file mode 100644 index 00000000..4a20db8f --- /dev/null +++ b/tests/common/server/test_hf2_server.py @@ -0,0 +1,73 @@ +from unittest.mock import MagicMock, patch + +import pytest +from zhinst.core import ziDAQServer + +from sm_bluesky.common.server import HF2Server + + +@pytest.fixture +def mock_daq(): + """Patches Serial and returns the class mock.""" + with patch( + "sm_bluesky.common.server.zurich_lockin_amplifier.ziDAQServer", spec=True + ) as mock_daq: + yield mock_daq + + +@pytest.fixture +def mock_server(mock_daq: ziDAQServer): + """Provides a fresh server instance with a mocked device for every test.""" + mock_server = HF2Server() + mock_server.device = mock_daq + return mock_server + + +def test_connect_hardware_success( + mock_server: HF2Server, caplog: pytest.LogCaptureFixture +): + mock_server._setup_scope = MagicMock() + mock_server.connect_hardware() + + mock_server._setup_scope.assert_called_once() + assert f"HF2 Data server connected at {mock_server.hf2_ip}" in caplog.text + + +def test_connect_hardware_failed( + mock_server: HF2Server, caplog: pytest.LogCaptureFixture, mock_daq: MagicMock +): + error_message = "Failed to Connect" + mock_daq.side_effect = Exception(error_message) + mock_server._send_error = MagicMock() + mock_server.connect_hardware() + mock_server._send_error.assert_called_once_with( + f"HF2 Connection failed: {error_message}" + ) + + +def test_disconnect_hardware_failed(mock_server: HF2Server): + with patch.object(mock_server, "device") as mock_device: + error_message = "Failed to disconnect" + mock_device.disconnect.side_effect = Exception(error_message) + mock_server._send_error = MagicMock() + mock_server.disconnect_hardware() + mock_server._send_error.assert_called_once_with( + f"Error during HF2 disconnect: {error_message}" + ) + + +def test_disconnect_hardware_failed_no_device(mock_server: HF2Server): + mock_server.device = None + mock_server._send_error = MagicMock() + mock_server.disconnect_hardware() + mock_server._send_error.assert_called_once_with( + "Attempted to disconnect hardware that was not connected" + ) + + +def test_disconnect_hardware(mock_server: HF2Server): + with patch.object(mock_server, "device") as mock_device: + mock_device.disconnect = MagicMock() + mock_server.disconnect_hardware() + mock_device.disconnect.assert_called_once() + assert mock_server.device is None From 574805ffcc7c7e650ce179363c091e5417c66b99 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 23 Mar 2026 16:55:50 +0000 Subject: [PATCH 22/41] more test --- tests/common/server/test_hf2_server.py | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_hf2_server.py index 4a20db8f..8b5b99f2 100644 --- a/tests/common/server/test_hf2_server.py +++ b/tests/common/server/test_hf2_server.py @@ -71,3 +71,30 @@ def test_disconnect_hardware(mock_server: HF2Server): mock_server.disconnect_hardware() mock_device.disconnect.assert_called_once() assert mock_server.device is None + + +@pytest.mark.parametrize( + "args, expected", + [ + ([b"10"], [10, 4096, 0, 0]), + ([b"10", b"11"], [10, 11, 0, 0]), + ([b"10", b"11", b"322"], [10, 11, 322, 0]), + ], +) +def test_setup_scope_success(args: list, expected: list, mock_server: HF2Server): + cmd = ["time", "length", "channels/0/inputselect", "enable"] + with patch.object(mock_server, "device") as mock_device: + mock_device.set = MagicMock() + mock_server._setup_scope(*args) + for i, arg in enumerate(mock_device.set.call_args_list): + assert arg.args == ( + f"/{mock_server.device_id}/scopes/0/{cmd[i]}", + expected[i], + ) + + +def test_setup_scope_failed_no_device(mock_server: HF2Server): + mock_server.device = None + mock_server._send_error = MagicMock() + with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): + mock_server._setup_scope() From d687d11acbaff01e046b1082078dcaa5b8ec0565 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 24 Mar 2026 11:20:31 +0000 Subject: [PATCH 23/41] tst for scope --- tests/common/server/test_hf2_server.py | 55 +++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_hf2_server.py index 8b5b99f2..d59506ef 100644 --- a/tests/common/server/test_hf2_server.py +++ b/tests/common/server/test_hf2_server.py @@ -1,5 +1,6 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch +import numpy as np import pytest from zhinst.core import ziDAQServer @@ -76,12 +77,12 @@ def test_disconnect_hardware(mock_server: HF2Server): @pytest.mark.parametrize( "args, expected", [ - ([b"10"], [10, 4096, 0, 0]), - ([b"10", b"11"], [10, 11, 0, 0]), - ([b"10", b"11", b"322"], [10, 11, 322, 0]), + ([10], [10, 4096, 0, 0]), + ([10, 11], [10, 11, 0, 0]), + ([10, 11, 322], [10, 11, 322, 0]), ], ) -def test_setup_scope_success(args: list, expected: list, mock_server: HF2Server): +def test_setup_scope_success(args: list[int], expected: list, mock_server: HF2Server): cmd = ["time", "length", "channels/0/inputselect", "enable"] with patch.object(mock_server, "device") as mock_device: mock_device.set = MagicMock() @@ -98,3 +99,47 @@ def test_setup_scope_failed_no_device(mock_server: HF2Server): mock_server._send_error = MagicMock() with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): mock_server._setup_scope() + + +@patch("sm_bluesky.common.server.zurich_lockin_amplifier.sleep") +def test_get_single_scope_shot_success(mock_sleep: MagicMock, mock_server: HF2Server): + + mock_server._scope_frequency = 1000 + mock_server.device = MagicMock() + mock_server.scope = MagicMock() + mock_wave = np.array([1.0, 2.0, 3.0, 4.0]) + mock_result = {"/dev4206/scopes/0/wave": [[{"wave": [mock_wave]}]]} + mock_server.scope.read.return_value = mock_result + result = mock_server._get_single_scope_shot() + assert result == 2.5 + + expected_device_calls = [ + call.__bool__(), + call.set("/dev4206/scopes/0/enable", 0), + call.setInt("/dev4206/scopes/0/single", 1), + call.setInt("/dev4206/scopes/0/enable", 1), + call.sync(), + call.set("/dev4206/scopes/0/enable", 0), + ] + mock_server.device.assert_has_calls(expected_device_calls, any_order=False) + expected_scope_calls = [ + call.__bool__(), + call.set("scopeModule/mode", 1), + call.subscribe("/dev4206/scopes/0/wave/"), + call.execute(), + call.finish(), + call.read(True), + call.unsubscribe("*"), + ] + mock_server.scope.assert_has_calls(expected_scope_calls, any_order=False) + assert 1.0 / 1000.0 + mock_server._minimum_scope_wait == pytest.approx( + mock_sleep.call_args.args[0], rel=0.01 + ) + + +def test_get_single_scope_shot_connection_error(mock_server: HF2Server): + """Verifies that it raises ConnectionError if components are missing.""" + mock_server.device = None # Simulate disconnected state + + with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): + mock_server._get_single_scope_shot() From a9b516b6a3aa1b02dde5e74742f5ae7d0d4cdd88 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 24 Mar 2026 15:42:24 +0000 Subject: [PATCH 24/41] complete the class --- .../common/server/zurich_lockin_amplifier.py | 260 ++++++++++++++++++ tests/common/server/test_hf2_server.py | 151 +++++++++- 2 files changed, 397 insertions(+), 14 deletions(-) create mode 100644 src/sm_bluesky/common/server/zurich_lockin_amplifier.py diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py new file mode 100644 index 00000000..49d544d7 --- /dev/null +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -0,0 +1,260 @@ +import functools +import inspect +from time import sleep, time +from typing import Literal + +import numpy as np +from zhinst.core import ScopeModule, ziDAQServer + +from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.log import LOGGER + + +def auto_type_cast(func): + """Automatically casts byte arguments to the types hinted + in the function signature.""" + + @functools.wraps(func) + def wrapper(self, *args: bytes): + sig = inspect.signature(func) + params = list(sig.parameters.values())[1:] + + casted_args = [] + for i, arg in enumerate(args): + if i < len(params): + target_type = params[i].annotation + try: + decoded = arg.decode("utf-8") + if target_type is float: + casted_args.append(float(decoded)) + elif target_type is int: + casted_args.append(int(decoded)) + else: + casted_args.append(decoded) + except ValueError as err: + raise ValueError( + f"Argument '{arg}' cannot be converted to " + f"{target_type.__name__}" + ) from err + else: + casted_args.append(arg) + + return func(self, *casted_args) + + return wrapper + + +class HF2Server(AbstractInstrumentServer): + """Python class to create a sever that connect to HF2 data server and listen for + data request from client.""" + + api_level: Literal[0, 1, 4, 5, 6] + + def __init__( + self, + host: str = "", + port: int = 7891, + hf2_ip: str = "172.23.110.84", + hf2_port: int = 8004, + api_level: Literal[0, 1, 4, 5, 6] = 6, + device_id: str = "dev4206", + ): + super().__init__(host, port) + self.hf2_ip = hf2_ip + self.hf2_port = hf2_port + self.api_level = api_level + self.device_id = device_id + self._minimum_scope_wait = 0.1 + self._device: ziDAQServer | None = None + self._scope: ScopeModule | None = None + self._scope_frequency: float | None = None + # Register HF2 specific commands + self._command_registry.update( + { + b"getData": self._get_combined_data, + b"autoVoltageInRange": self._auto_voltage_range, + b"setTimeConstant": self._set_time_constant, + b"setDataRate": self._set_data_rate, + b"setCurrentInRange": self._set_current_range, + b"autoCurrentInRange": self._auto_current_range, + b"setRefFreq": self._set_ref_freq, + b"setRefV": self._set_ref_vpk, + b"setRefVoff": self._set_ref_voff, + b"setsRefOutSwitch": self._set_ref_output, + b"setsRefHarm": self._set_ref_harmonic, + b"setupScope": self._setup_scope_cmd, + } + ) + + @property + def device(self) -> ziDAQServer: + if self._device is None: + raise ConnectionError("Lockin amplifier not connected") + return self._device + + @device.setter + def device(self, value: ziDAQServer | None): + self._device = value + + @property + def scope(self) -> ScopeModule: + if self._scope is None: + raise ConnectionError( + "Scope module not initialized. Run setupScope before using scope." + ) + return self._scope + + @scope.setter + def scope(self, value: ScopeModule | None): + self._scope = value + + # --- Example Method --- + + def connect_hardware(self) -> bool: + """Connect to Zurich Instruments HF2 Data Server.""" + try: + self.device = ziDAQServer(self.hf2_ip, self.hf2_port, self.api_level) + self._setup_scope() + LOGGER.info(f"HF2 Data server connected at {self.hf2_ip}") + return True + except Exception as e: + self._error_helper("HF2 Connection failed", e) + return False + + def disconnect_hardware(self) -> None: + """Disconnect from HF2 and cleanup modules.""" + try: + self.device.disconnect() + LOGGER.info("HF2 disconnected") + except Exception as e: + self._error_helper("Error during HF2 disconnect", e) + finally: + self.device = None + + # --- Hardware Logic Methods --- + def _setup_scope(self, freq: float = 5.0, length: int = 4096, channel: int = 0): + self.scope = self.device.scopeModule() + self._scope_frequency = 5.0 + self.device.set(f"/{self.device_id}/scopes/0/time", freq) + self.device.set(f"/{self.device_id}/scopes/0/length", length) + self.device.set(f"/{self.device_id}/scopes/0/channels/0/inputselect", channel) + self.device.set(f"/{self.device_id}/scopes/0/enable", 0) + + def _get_single_scope_shot(self) -> float: + """Returns the mean value of a single scope shot.""" + if self._scope_frequency: + self.device.set(f"/{self.device_id}/scopes/0/enable", 0) + self.scope.set("scopeModule/mode", 1) + self.scope.subscribe(f"/{self.device_id}/scopes/0/wave/") + self.scope.execute() + self.device.setInt(f"/{self.device_id}/scopes/0/single", 1) + self.device.setInt(f"/{self.device_id}/scopes/0/enable", 1) + self.device.sync() + sleep(1.0 / self._scope_frequency + self._minimum_scope_wait) + self.scope.finish() + result = self.scope.read(True) + static_mean = result[f"/{self.device_id}/scopes/0/wave"][0][0]["wave"][ + 0 + ].mean() + self.scope.unsubscribe("*") + self.device.set(f"/{self.device_id}/scopes/0/enable", 0) + return float(static_mean) + else: + raise ValueError( + "Scope frequency not set, use 'setupScope' before taking data." + ) + + def _get_lockin_data(self, duration: float) -> tuple[float, float, float, float]: + """Averages demodulator data over a specific duration.""" + x, y = [], [] + start_time = time() + end_time = start_time + duration + start_avg_time = start_time + (duration * 0.5) + + # Wait for stabilization + while time() < start_avg_time: + sleep(0.01) + + # Collect samples + while time() < end_time: + sample = self.device.getSample(f"/{self.device_id}/demods/0/sample") + x.append(sample["x"]) + y.append(sample["y"]) + + avg_x, avg_y = float(np.mean(x)), float(np.mean(y)) + r = float(np.abs(avg_x + 1j * avg_y)) + theta = float(np.rad2deg(np.arctan2(avg_y, avg_x))) + return avg_x, avg_y, r, theta + + def _set_node(self, path: str, value: float | int, response_msg: bytes): + if isinstance(value, int): + self.device.setInt(f"/{self.device_id}/{path}", value) + self._send_response(response_msg + b": %i" % value) + else: + self.device.setDouble(f"/{self.device_id}/{path}", value) + self._send_response(response_msg + b": %f" % value) + + # --- Command Handlers --- + def _get_combined_data(self, value: bytes = b"0.1"): + duration = float(value.decode()) + x, y, r, theta = self._get_lockin_data(duration) + static = self._get_single_scope_shot() + response = f"{x:e}, {y:e}, {theta:f}, {static:e}, {r:e}" + self._send_response(response.encode()) + + def _set_current_range(self, value: bytes): + raw_val = float(value) + exponent = int(np.floor(np.log10(raw_val))) + self.device.setDouble(f"/{self.device_id}/currins/0/range", 10**exponent) + self._send_response(b"Current range set") + + def _set_ref_output(self, value: bytes): + state = 1 if value.strip().lower() == b"on" else 0 + self.device.setInt(f"/{self.device_id}/sigouts/0/enables/1", state) + self._send_response(f"Output set to {state}".encode()) + + @auto_type_cast + def _setup_scope_cmd(self, freq: float = 5.0, length: int = 4096, channel: int = 0): + self._setup_scope(freq, length, channel) + self._send_response(b"Scope configured") + + # Add command mappings... + def _auto_voltage_range(self): + self._set_node( + path="sigins/0/autorange", value=1, response_msg=b"Auto voltage triggered" + ) + + def _auto_current_range(self): + self._set_node( + path="currins/0/autorange", value=1, response_msg=b"Auto current triggered" + ) + + @auto_type_cast + def _set_time_constant(self, val: float): + self._set_node( + path="demods/0/timeconstant", value=val, response_msg=b"Time constant set" + ) + + @auto_type_cast + def _set_ref_freq(self, val: float): + self._set_node(path="oscs/0/freq", value=val, response_msg=b"Frequency set") + + @auto_type_cast + def _set_data_rate(self, val: float): + self._set_node(path="demods/0/rate", value=val, response_msg=b"Data rate set") + + @auto_type_cast + def _set_ref_vpk(self, val: float): + self._set_node( + path="sigouts/0/amplitudes/1", value=val, response_msg=b"Ref Vpk set" + ) + + @auto_type_cast + def _set_ref_voff(self, val: float): + self._set_node(path="sigouts/0/offset", value=val, response_msg=b"Ref Voff set") + + @auto_type_cast + def _set_ref_harmonic(self, val: float): + self._set_node( + path="demods/1/harmonic", value=val, response_msg=b"Harmonic set" + ) diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_hf2_server.py index d59506ef..367f0632 100644 --- a/tests/common/server/test_hf2_server.py +++ b/tests/common/server/test_hf2_server.py @@ -47,7 +47,7 @@ def test_connect_hardware_failed( def test_disconnect_hardware_failed(mock_server: HF2Server): - with patch.object(mock_server, "device") as mock_device: + with patch.object(mock_server, "_device") as mock_device: error_message = "Failed to disconnect" mock_device.disconnect.side_effect = Exception(error_message) mock_server._send_error = MagicMock() @@ -62,16 +62,16 @@ def test_disconnect_hardware_failed_no_device(mock_server: HF2Server): mock_server._send_error = MagicMock() mock_server.disconnect_hardware() mock_server._send_error.assert_called_once_with( - "Attempted to disconnect hardware that was not connected" + "Error during HF2 disconnect: Lockin amplifier not connected" ) def test_disconnect_hardware(mock_server: HF2Server): - with patch.object(mock_server, "device") as mock_device: + with patch.object(mock_server, "_device") as mock_device: mock_device.disconnect = MagicMock() mock_server.disconnect_hardware() mock_device.disconnect.assert_called_once() - assert mock_server.device is None + assert mock_server._device is None @pytest.mark.parametrize( @@ -84,7 +84,7 @@ def test_disconnect_hardware(mock_server: HF2Server): ) def test_setup_scope_success(args: list[int], expected: list, mock_server: HF2Server): cmd = ["time", "length", "channels/0/inputselect", "enable"] - with patch.object(mock_server, "device") as mock_device: + with patch.object(mock_server, "_device") as mock_device: mock_device.set = MagicMock() mock_server._setup_scope(*args) for i, arg in enumerate(mock_device.set.call_args_list): @@ -105,25 +105,23 @@ def test_setup_scope_failed_no_device(mock_server: HF2Server): def test_get_single_scope_shot_success(mock_sleep: MagicMock, mock_server: HF2Server): mock_server._scope_frequency = 1000 - mock_server.device = MagicMock() - mock_server.scope = MagicMock() + mock_server._device = MagicMock() + mock_server._scope = MagicMock() mock_wave = np.array([1.0, 2.0, 3.0, 4.0]) mock_result = {"/dev4206/scopes/0/wave": [[{"wave": [mock_wave]}]]} - mock_server.scope.read.return_value = mock_result + mock_server._scope.read.return_value = mock_result result = mock_server._get_single_scope_shot() assert result == 2.5 expected_device_calls = [ - call.__bool__(), call.set("/dev4206/scopes/0/enable", 0), call.setInt("/dev4206/scopes/0/single", 1), call.setInt("/dev4206/scopes/0/enable", 1), call.sync(), call.set("/dev4206/scopes/0/enable", 0), ] - mock_server.device.assert_has_calls(expected_device_calls, any_order=False) + mock_server._device.assert_has_calls(expected_device_calls, any_order=False) expected_scope_calls = [ - call.__bool__(), call.set("scopeModule/mode", 1), call.subscribe("/dev4206/scopes/0/wave/"), call.execute(), @@ -131,7 +129,7 @@ def test_get_single_scope_shot_success(mock_sleep: MagicMock, mock_server: HF2Se call.read(True), call.unsubscribe("*"), ] - mock_server.scope.assert_has_calls(expected_scope_calls, any_order=False) + mock_server._scope.assert_has_calls(expected_scope_calls, any_order=False) assert 1.0 / 1000.0 + mock_server._minimum_scope_wait == pytest.approx( mock_sleep.call_args.args[0], rel=0.01 ) @@ -139,7 +137,132 @@ def test_get_single_scope_shot_success(mock_sleep: MagicMock, mock_server: HF2Se def test_get_single_scope_shot_connection_error(mock_server: HF2Server): """Verifies that it raises ConnectionError if components are missing.""" - mock_server.device = None # Simulate disconnected state - + mock_server._device = None + mock_server._scope_frequency = 1 with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): mock_server._get_single_scope_shot() + + +def test_get_single_scope_shot_scope_error(mock_server: HF2Server): + """Verifies that it raises ConnectionError if components are missing.""" + mock_server._scope = None + mock_server._scope_frequency = 1 + with pytest.raises( + ConnectionError, + match="Scope module not initialized. Run setupScope before using scope.", + ): + mock_server._get_single_scope_shot() + + +def test_get_single_scope_shot_frequncy_error(mock_server: HF2Server): + """Verifies that it raises ConnectionError if components are missing.""" + with pytest.raises( + ValueError, + match="Scope frequency not set, use 'setupScope' before taking data.", + ): + mock_server._get_single_scope_shot() + + +@patch("sm_bluesky.common.server.zurich_lockin_amplifier.time") +@patch("sm_bluesky.common.server.zurich_lockin_amplifier.sleep") +def test_get_lockin_data_averaging( + mock_sleep: MagicMock, mock_time: MagicMock, mock_server: HF2Server +): + mock_server._device = MagicMock() + mock_time.side_effect = [100, 100.2, 100.4, 100.7, 100.8, 100.9, 102] + mock_server._device.getSample.side_effect = [ + {"x": 1.0, "y": 2.0}, + {"x": 3.0, "y": 4.0}, + ] + + x, y, r, theta = mock_server._get_lockin_data(1.0) + + assert x == 2.0 + assert y == 3.0 + assert r == pytest.approx(3.60555, rel=1e-4) + + assert theta == pytest.approx(56.3099, rel=1e-4) + mock_sleep.assert_called_with(0.01) + assert mock_server._device.getSample.call_count == 2 + + +def test_get_lockin_data_fail(mock_server: HF2Server): + mock_server.device = None + with pytest.raises(ConnectionError, match="Lockin amplifier not connected"): + mock_server._get_lockin_data(0.1) + + +@pytest.mark.parametrize( + "method_name, val_bytes, expected_path, expected_val, expected_response", + [ + ( + "_set_time_constant", + b"0.01", + "/dev4206/demods/0/timeconstant", + 0.01, + b"Time constant set", + ), + ("_set_data_rate", b"400", "/dev4206/demods/0/rate", 400.0, b"Data rate set"), + ( + "_set_ref_vpk", + b"0.5", + "/dev4206/sigouts/0/amplitudes/1", + 0.5, + b"Ref Vpk set", + ), + ("_set_ref_voff", b"0.1", "/dev4206/sigouts/0/offset", 0.1, b"Ref Voff set"), + ( + "_set_ref_harmonic", + b"2.0", + "/dev4206/demods/1/harmonic", + 2.0, + b"Harmonic set", + ), + ("_set_ref_freq", b"20.5", "/dev4206/oscs/0/freq", 20.5, b"Frequency set"), + ], +) +def test_commond_mapping_method_double( + mock_server: HF2Server, + method_name: str, + val_bytes: bytes, + expected_path: str, + expected_val: float, + expected_response: bytes, +): + method = getattr(mock_server, method_name) + mock_server._device = MagicMock() + mock_server._send_response = MagicMock() + method(val_bytes) + mock_server._device.setDouble.assert_called_once_with(expected_path, expected_val) + response = expected_response + b": %f" % expected_val + mock_server._send_response.assert_called_once_with(response) + + +@pytest.mark.parametrize( + "method_name, expected_path, expected_response", + [ + ( + "_auto_voltage_range", + "/dev4206/sigins/0/autorange", + b"Auto voltage triggered", + ), + ( + "_auto_current_range", + "/dev4206/currins/0/autorange", + b"Auto current triggered", + ), + ], +) +def test_commond_mapping_method_int( + mock_server: HF2Server, + method_name: str, + expected_path: str, + expected_response: bytes, +): + method = getattr(mock_server, method_name) + mock_server._send_response = MagicMock() + mock_server._device = MagicMock() + method() + + mock_server._device.setInt.assert_called_once_with(expected_path, 1) + mock_server._send_response.assert_called_once_with(expected_response + b": 1") From 9872ef144559250e3fb0bdbf19b1ffc35b3bfbdf Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 24 Mar 2026 15:44:26 +0000 Subject: [PATCH 25/41] change args to a list --- src/sm_bluesky/common/server/abstract_instrument_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 2a0c7911..03250f78 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -143,7 +143,8 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - handler(args) if args else handler() + arg_list = args.split(b"\t") if args else [] + handler(*arg_list) except Exception as e: self._error_helper( From 2006d1942c61446e0591bb53bc5f6e62bf6bf7d9 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 24 Mar 2026 16:15:15 +0000 Subject: [PATCH 26/41] add some more test --- .../common/server/zurich_lockin_amplifier.py | 28 +++++++++++-------- tests/common/server/test_hf2_server.py | 20 +++++++++++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py index 49d544d7..7fd274b3 100644 --- a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -195,23 +195,29 @@ def _set_node(self, path: str, value: float | int, response_msg: bytes): self._send_response(response_msg + b": %f" % value) # --- Command Handlers --- - def _get_combined_data(self, value: bytes = b"0.1"): - duration = float(value.decode()) + @auto_type_cast + def _get_combined_data(self, duration: float = 0.1): x, y, r, theta = self._get_lockin_data(duration) static = self._get_single_scope_shot() response = f"{x:e}, {y:e}, {theta:f}, {static:e}, {r:e}" self._send_response(response.encode()) - def _set_current_range(self, value: bytes): - raw_val = float(value) - exponent = int(np.floor(np.log10(raw_val))) - self.device.setDouble(f"/{self.device_id}/currins/0/range", 10**exponent) - self._send_response(b"Current range set") + @auto_type_cast + def _set_current_range(self, value: float): + # current range is in multiple of 10 between 1e-9 to 1e-2 + exponent = int(np.floor(np.log10(value))) + + self._set_node( + path="currins/0/range", + value=10.0**exponent, + response_msg=b"Current range set", + ) - def _set_ref_output(self, value: bytes): - state = 1 if value.strip().lower() == b"on" else 0 - self.device.setInt(f"/{self.device_id}/sigouts/0/enables/1", state) - self._send_response(f"Output set to {state}".encode()) + @auto_type_cast + def _set_ref_output(self, value: int): + self._set_node( + path="sigouts/0/enables/1", value=value, response_msg=b"Output set to" + ) @auto_type_cast def _setup_scope_cmd(self, freq: float = 5.0, length: int = 4096, channel: int = 0): diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_hf2_server.py index 367f0632..bfd4a3f3 100644 --- a/tests/common/server/test_hf2_server.py +++ b/tests/common/server/test_hf2_server.py @@ -219,6 +219,13 @@ def test_get_lockin_data_fail(mock_server: HF2Server): b"Harmonic set", ), ("_set_ref_freq", b"20.5", "/dev4206/oscs/0/freq", 20.5, b"Frequency set"), + ( + "_set_current_range", + b"1e-4", + "/dev4206/currins/0/range", + 10.0**-4, + b"Current range set", + ), ], ) def test_commond_mapping_method_double( @@ -239,30 +246,39 @@ def test_commond_mapping_method_double( @pytest.mark.parametrize( - "method_name, expected_path, expected_response", + "method_name, val_bytes, expected_path, expected_response", [ ( "_auto_voltage_range", + [], "/dev4206/sigins/0/autorange", b"Auto voltage triggered", ), ( "_auto_current_range", + [], "/dev4206/currins/0/autorange", b"Auto current triggered", ), + ( + "_set_ref_output", + [b"1"], + "/dev4206/sigouts/0/enables/1", + b"Output set to", + ), ], ) def test_commond_mapping_method_int( mock_server: HF2Server, method_name: str, + val_bytes: bytes, expected_path: str, expected_response: bytes, ): method = getattr(mock_server, method_name) mock_server._send_response = MagicMock() mock_server._device = MagicMock() - method() + method(*val_bytes) mock_server._device.setInt.assert_called_once_with(expected_path, 1) mock_server._send_response.assert_called_once_with(expected_response + b": 1") From 1e52fd843c04f2314457a73720e0f50205209710 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 10:55:19 +0000 Subject: [PATCH 27/41] _get_combine_data test --- .../common/server/zurich_lockin_amplifier.py | 2 +- tests/common/server/test_hf2_server.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py index 7fd274b3..e46339d3 100644 --- a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -196,7 +196,7 @@ def _set_node(self, path: str, value: float | int, response_msg: bytes): # --- Command Handlers --- @auto_type_cast - def _get_combined_data(self, duration: float = 0.1): + def _get_combined_data(self, duration: float = 0.1) -> None: x, y, r, theta = self._get_lockin_data(duration) static = self._get_single_scope_shot() response = f"{x:e}, {y:e}, {theta:f}, {static:e}, {r:e}" diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_hf2_server.py index bfd4a3f3..51ba91aa 100644 --- a/tests/common/server/test_hf2_server.py +++ b/tests/common/server/test_hf2_server.py @@ -282,3 +282,19 @@ def test_commond_mapping_method_int( mock_server._device.setInt.assert_called_once_with(expected_path, 1) mock_server._send_response.assert_called_once_with(expected_response + b": 1") + + +def test_get_combined_data( + mock_server: HF2Server, +): + duration = b"0.2" + mock_server._get_lockin_data = MagicMock(return_value={1, 2, 3, 4}) + mock_server._get_single_scope_shot = MagicMock(return_value=5) + mock_server._send_response = MagicMock() + mock_server._get_combined_data(duration) # type: ignore + mock_server._get_lockin_data.assert_called_once_with( + float(duration.decode("utf-8")) + ) + mock_server._get_single_scope_shot.assert_called_once() + response = f"{1:e}, {2:e}, {4:f}, {5:e}, {3:e}".encode() + mock_server._send_response.assert_called_once_with(response) From bffd1c216d442aa31e97d2914abb239c92ab0dfa Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 11:49:19 +0000 Subject: [PATCH 28/41] add contextmanager for hardware not responding --- .../server/abstract_instrument_server.py | 25 +++++++++++++++++-- .../server/test_abstract_instrument_server.py | 25 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 03250f78..f6659855 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,3 +1,4 @@ +import signal import socket from abc import ABC, abstractmethod from collections.abc import Callable @@ -70,6 +71,20 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): self._disconnect_client() LOGGER.info(f"Client {addr} disconnected. Server ready.") + @contextmanager + def _hardware_watch(self, seconds: int = 60): + """Context manager to interrupt hardware calls that took too long.""" + + def handler(signum, frame): + raise TimeoutError(f"Hardware call timed out after {seconds}s") + + signal.signal(signal.SIGALRM, handler) + signal.alarm(seconds) + try: + yield + finally: + signal.alarm(0) + def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" self._disconnect_client() @@ -143,9 +158,15 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - arg_list = args.split(b"\t") if args else [] - handler(*arg_list) + with self._hardware_watch(seconds=60): + arg_list = args.split(b"\t") if args else [] + handler(*arg_list) + except TimeoutError as te: + self._error_helper( + f"Error handling command: {cmd.decode()} - hardware not responding", + te, + ) except Exception as e: self._error_helper( message=f"Error handling command '{cmd.decode()}'", error=e diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 862477b8..fb382dce 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,3 +1,4 @@ +import signal import socket from unittest.mock import MagicMock, patch @@ -219,3 +220,27 @@ def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): mock_instrument._handle_command.assert_called_once_with( b"command", b"argument\targument2" ) + + +def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): + """Tests that a TimeoutError (hardware_Watch trip) is caught and reported + correctly.""" + + cmd = b"getData" + args = b"0.1" + mock_handler = MagicMock(side_effect=TimeoutError("Hardware hung")) + mock_instrument._command_registry[cmd] = mock_handler + mock_instrument._error_helper = MagicMock() + mock_instrument._handle_command(cmd, args) + mock_instrument._error_helper.assert_called_once() + args_called, _ = mock_instrument._error_helper.call_args + assert "hardware not responding" in args_called[0].lower() + assert isinstance(args_called[1], TimeoutError) + + +def test_hardware_watch_handler_definition(mock_instrument): + with patch("signal.signal") as mock_signal_reg: + with mock_instrument._hardware_watch(seconds=5): + captured_handler = mock_signal_reg.call_args[0][1] + with pytest.raises(TimeoutError, match="Hardware call timed out after 5s"): + captured_handler(signal.SIGALRM, None) From 1c43785027a4eaaccbe5b861c8bfbeaeafb1e371 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 11:50:47 +0000 Subject: [PATCH 29/41] complete hardware_watch test --- tests/common/server/test_abstract_instrument_server.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index fb382dce..0c30630c 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -244,3 +244,11 @@ def test_hardware_watch_handler_definition(mock_instrument): captured_handler = mock_signal_reg.call_args[0][1] with pytest.raises(TimeoutError, match="Hardware call timed out after 5s"): captured_handler(signal.SIGALRM, None) + + +def test_hardware_watch_disarms_on_success(mock_instrument): + with patch("signal.alarm") as mock_alarm: + with mock_instrument._hardware_watch(seconds=10): + mock_alarm.assert_called_with(10) + + mock_alarm.assert_called_with(0) From 59602ee04863a842564f9f7b1862704cbca6dc41 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 12:22:37 +0000 Subject: [PATCH 30/41] fix sigal required main thread. --- .../server/abstract_instrument_server.py | 27 ++++++++---- .../server/test_abstract_instrument_server.py | 41 +++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index f6659855..600b840e 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,6 @@ -import signal +import ctypes import socket +import threading from abc import ABC, abstractmethod from collections.abc import Callable from contextlib import contextmanager @@ -74,16 +75,28 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): @contextmanager def _hardware_watch(self, seconds: int = 60): """Context manager to interrupt hardware calls that took too long.""" + target_tid = threading.get_ident() + stop_event = threading.Event() + + def trigger_timeout(): + if not stop_event.wait(timeout=seconds): + # Inject TimeoutError and kill the thread + res = ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(target_tid), ctypes.py_object(TimeoutError) + ) + if res > 1: + # If it affected more than one thread, undo it! + ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(target_tid), None + ) - def handler(signum, frame): - raise TimeoutError(f"Hardware call timed out after {seconds}s") + monitor = threading.Thread(target=trigger_timeout, daemon=True) + monitor.start() - signal.signal(signal.SIGALRM, handler) - signal.alarm(seconds) try: yield finally: - signal.alarm(0) + stop_event.set() def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" @@ -153,7 +166,7 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: if not handler: self._error_helper( message=f"Received unknown command: '{cmd.decode()}'", - error=Exception("Unknow command"), + error=Exception("Unknown command"), level=logging.WARNING, ) else: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 0c30630c..18f97fa3 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,5 +1,5 @@ -import signal import socket +import time from unittest.mock import MagicMock, patch import pytest @@ -130,7 +130,7 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") mock_instrument._conn.sendall.assert_called_once_with( - b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknow command\n" + b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknown command\n" ) @@ -238,17 +238,32 @@ def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): assert isinstance(args_called[1], TimeoutError) -def test_hardware_watch_handler_definition(mock_instrument): - with patch("signal.signal") as mock_signal_reg: - with mock_instrument._hardware_watch(seconds=5): - captured_handler = mock_signal_reg.call_args[0][1] - with pytest.raises(TimeoutError, match="Hardware call timed out after 5s"): - captured_handler(signal.SIGALRM, None) +def test_hardware_watch_thread_lifecycle(mock_instrument): + """Verifies that the watchdog starts a monitor thread and cleans up.""" + with patch("threading.Thread") as mock_thread: + mock_thread_instance = mock_thread.return_value - -def test_hardware_watch_disarms_on_success(mock_instrument): - with patch("signal.alarm") as mock_alarm: with mock_instrument._hardware_watch(seconds=10): - mock_alarm.assert_called_with(10) + mock_thread.assert_called_once() + mock_thread_instance.start.assert_called_once() + + +def test_hardware_watch_injection(mock_instrument): + cmd = b"hang" + + def hanging_command(): + time.sleep(0.1) # Sleep longer than the watchdog + + mock_instrument._command_registry[cmd] = hanging_command + mock_instrument._error_helper = MagicMock() - mock_alarm.assert_called_with(0) + with patch.object( + mock_instrument, + "_handle_command", + side_effect=lambda c, a: mock_instrument.__class__._handle_command( + mock_instrument, c, a + ), + ): + with pytest.raises(TimeoutError): + with mock_instrument._hardware_watch(seconds=0.01): + hanging_command() From 45daa7502217d87decd913e8e5c9bffcfca30579 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 12:31:02 +0000 Subject: [PATCH 31/41] cleanup test --- .../server/test_abstract_instrument_server.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 18f97fa3..92a69270 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -252,18 +252,11 @@ def test_hardware_watch_injection(mock_instrument): cmd = b"hang" def hanging_command(): - time.sleep(0.1) # Sleep longer than the watchdog + time.sleep(0.1) mock_instrument._command_registry[cmd] = hanging_command mock_instrument._error_helper = MagicMock() - with patch.object( - mock_instrument, - "_handle_command", - side_effect=lambda c, a: mock_instrument.__class__._handle_command( - mock_instrument, c, a - ), - ): - with pytest.raises(TimeoutError): - with mock_instrument._hardware_watch(seconds=0.01): - hanging_command() + with pytest.raises(TimeoutError): + with mock_instrument._hardware_watch(seconds=0.01): + hanging_command() From 598c76b371244415a9383d9bba7552bcd2b58105 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 16:27:30 +0000 Subject: [PATCH 32/41] move casting to decorators --- .../beamlines/p99/plans/__init__.py | 2 +- src/sm_bluesky/common/helper/__init__.py | 3 - src/sm_bluesky/common/plans/fast_scan.py | 2 +- .../common/server/zurich_lockin_amplifier.py | 37 +-- src/sm_bluesky/common/utils/__init__.py | 3 + .../add_meta.py => utils/decorators.py} | 36 ++- .../test_decorators.py} | 51 ++- uv.lock | 296 +++++++++--------- 8 files changed, 238 insertions(+), 192 deletions(-) delete mode 100644 src/sm_bluesky/common/helper/__init__.py create mode 100644 src/sm_bluesky/common/utils/__init__.py rename src/sm_bluesky/common/{helper/add_meta.py => utils/decorators.py} (53%) rename tests/common/{helper/test_helper.py => utils/test_decorators.py} (68%) diff --git a/src/sm_bluesky/beamlines/p99/plans/__init__.py b/src/sm_bluesky/beamlines/p99/plans/__init__.py index 1b48e674..18428026 100644 --- a/src/sm_bluesky/beamlines/p99/plans/__init__.py +++ b/src/sm_bluesky/beamlines/p99/plans/__init__.py @@ -1,5 +1,5 @@ -from sm_bluesky.common.helper import add_default_metadata from sm_bluesky.common.plans import grid_fast_scan, grid_step_scan +from sm_bluesky.common.utils import add_default_metadata P99_DEFAULT_METADATA = { "energy": {"value": 1.8, "unit": "eV"}, diff --git a/src/sm_bluesky/common/helper/__init__.py b/src/sm_bluesky/common/helper/__init__.py deleted file mode 100644 index 1c80c622..00000000 --- a/src/sm_bluesky/common/helper/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .add_meta import add_default_metadata, add_extra_names_to_meta - -__all__ = ["add_default_metadata", "add_extra_names_to_meta"] diff --git a/src/sm_bluesky/common/plans/fast_scan.py b/src/sm_bluesky/common/plans/fast_scan.py index 40c5421a..8bb14f0a 100644 --- a/src/sm_bluesky/common/plans/fast_scan.py +++ b/src/sm_bluesky/common/plans/fast_scan.py @@ -12,8 +12,8 @@ from ophyd_async.core import FlyMotorInfo from ophyd_async.epics.motor import Motor -from sm_bluesky.common.helper import add_extra_names_to_meta from sm_bluesky.common.plan_stubs import check_within_limit +from sm_bluesky.common.utils import add_extra_names_to_meta from sm_bluesky.log import LOGGER diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py index e46339d3..0da445ed 100644 --- a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -1,5 +1,3 @@ -import functools -import inspect from time import sleep, time from typing import Literal @@ -7,43 +5,10 @@ from zhinst.core import ScopeModule, ziDAQServer from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.common.utils import auto_type_cast from sm_bluesky.log import LOGGER -def auto_type_cast(func): - """Automatically casts byte arguments to the types hinted - in the function signature.""" - - @functools.wraps(func) - def wrapper(self, *args: bytes): - sig = inspect.signature(func) - params = list(sig.parameters.values())[1:] - - casted_args = [] - for i, arg in enumerate(args): - if i < len(params): - target_type = params[i].annotation - try: - decoded = arg.decode("utf-8") - if target_type is float: - casted_args.append(float(decoded)) - elif target_type is int: - casted_args.append(int(decoded)) - else: - casted_args.append(decoded) - except ValueError as err: - raise ValueError( - f"Argument '{arg}' cannot be converted to " - f"{target_type.__name__}" - ) from err - else: - casted_args.append(arg) - - return func(self, *casted_args) - - return wrapper - - class HF2Server(AbstractInstrumentServer): """Python class to create a sever that connect to HF2 data server and listen for data request from client.""" diff --git a/src/sm_bluesky/common/utils/__init__.py b/src/sm_bluesky/common/utils/__init__.py new file mode 100644 index 00000000..96d11711 --- /dev/null +++ b/src/sm_bluesky/common/utils/__init__.py @@ -0,0 +1,3 @@ +from .decorators import add_default_metadata, add_extra_names_to_meta, auto_type_cast + +__all__ = ["add_default_metadata", "add_extra_names_to_meta", "auto_type_cast"] diff --git a/src/sm_bluesky/common/helper/add_meta.py b/src/sm_bluesky/common/utils/decorators.py similarity index 53% rename from src/sm_bluesky/common/helper/add_meta.py rename to src/sm_bluesky/common/utils/decorators.py index 1bffcf8a..582156b0 100644 --- a/src/sm_bluesky/common/helper/add_meta.py +++ b/src/sm_bluesky/common/utils/decorators.py @@ -1,6 +1,7 @@ +import inspect from collections.abc import Callable from functools import wraps -from typing import Any, TypeVar, cast +from typing import Any, ParamSpec, TypeVar, cast, get_type_hints from bluesky.utils import MsgGenerator @@ -46,3 +47,36 @@ def add_extra_names_to_meta( return md md[key] = names return md + + +P = ParamSpec("P") +R = TypeVar("R") + + +def auto_type_cast(func: Callable[P, R]) -> Callable[P, R]: + """ + Casts positional byte arguments to hinted types. + Skips 'self' and handles empty strings gracefully. + """ + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + sig = inspect.signature(func) + hints = get_type_hints(func) + + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + for name, value in bound_args.arguments.items(): + if isinstance(value, bytes) and name in hints: + target_type = hints[name] + try: + str_val = value.decode("utf-8") + if target_type in (int, float, str): + bound_args.arguments[name] = target_type(str_val) + except (ValueError, UnicodeDecodeError) as e: + raise TypeError(f"Argument '{name}' casting failed: {e}") from e + + return func(*bound_args.args, **bound_args.kwargs) + + return wrapper diff --git a/tests/common/helper/test_helper.py b/tests/common/utils/test_decorators.py similarity index 68% rename from tests/common/helper/test_helper.py rename to tests/common/utils/test_decorators.py index 629913c9..fad855a7 100644 --- a/tests/common/helper/test_helper.py +++ b/tests/common/utils/test_decorators.py @@ -5,11 +5,12 @@ from bluesky.plans import count from bluesky.run_engine import RunEngine -from sm_bluesky.common.helper.add_meta import ( +from sm_bluesky.common.sim_devices import SimStage +from sm_bluesky.common.utils.decorators import ( add_default_metadata, add_extra_names_to_meta, + auto_type_cast, ) -from sm_bluesky.common.sim_devices import SimStage DEFAULT_METADATA = { "energy": {"value": 1.8, "unit": "eV"}, @@ -97,3 +98,49 @@ def test_add_extra_names_to_meta_dictionary_fail_value_not_list() -> None: md = {"Bound": some_plan} with pytest.raises(TypeError): md = add_extra_names_to_meta(md=md, key="Bound", names=["James"]) + + +class TestCasting: + @auto_type_cast + def set_params( + self, + integer: int = 1, + double: float = 5.0, + string: str = "string", + ): + return integer, double, string + + +@pytest.fixture +def test_casting(): + return TestCasting() + + +def test_auto_type_cast_default(test_casting: TestCasting): + interger, number, string = test_casting.set_params() + + assert interger == 1 + assert number == 5 + assert string == "string" + assert isinstance(interger, int) + assert isinstance(number, float) + assert isinstance(string, str) + + +def test_cast_invalid(test_casting: TestCasting): + with pytest.raises(TypeError, match="Argument 'integer' casting failed"): + test_casting.set_params(b"not_an_int", b"1.0", b"test") # pyright: ignore[reportArgumentType] + + +@pytest.mark.parametrize( + "test_input, expected_result", + [ + ([b"5", b"6.5", b"hello"], (5, 6.5, "hello")), + ([b"10", b"0.0", b"world"], (10, 0.0, "world")), + ([b"1", b"1.1"], (1, 1.1, "string")), + ([b"1"], (1, 5, "string")), + ], +) +def test_auto_type_cast_multi(test_input, expected_result, test_casting: TestCasting): + result = test_casting.set_params(*test_input) + assert result == expected_result diff --git a/uv.lock b/uv.lock index e5a8ec53..b34c20d4 100644 --- a/uv.lock +++ b/uv.lock @@ -203,15 +203,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -1028,61 +1028,61 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, ] [[package]] @@ -1351,7 +1351,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1360,9 +1360,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [package.optional-dependencies] @@ -1401,7 +1401,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.15.0" +version = "0.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastar" }, @@ -1413,9 +1413,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/e1/05c44e7bbc619e980fab0236cff9f5f323ac1aaa79434b4906febf98b1d3/fastapi_cloud_cli-0.15.0.tar.gz", hash = "sha256:d02515231f3f505f7669c20920343934570a88a08af9f9a6463ca2807f27ffe5", size = 45309, upload-time = "2026-03-11T22:31:32.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f2/fcd66ce245b7e3c3d84ca8717eda8896945fbc17c87a9b03f490ff06ace7/fastapi_cloud_cli-0.15.1.tar.gz", hash = "sha256:71a46f8a1d9fea295544113d6b79f620dc5768b24012887887306d151165745d", size = 43851, upload-time = "2026-03-26T10:23:12.932Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/cc/1ccca747f5609be27186ea8c9219449142f40e3eded2c6089bba6a6ecc82/fastapi_cloud_cli-0.15.0-py3-none-any.whl", hash = "sha256:9ffcf90bd713747efa65447620d29cfbb7b3f7de38d97467952ca6346e418d70", size = 32267, upload-time = "2026-03-11T22:31:33.499Z" }, + { url = "https://files.pythonhosted.org/packages/b2/11/ecb0d5e1d114e8aaec1cdc8ee2d7b0f54292585067effe2756bde7e7a4b0/fastapi_cloud_cli-0.15.1-py3-none-any.whl", hash = "sha256:b1e8b3b26dc314e180fc0ab67dfd39d7d9fe160d3951081d09184eafaacf5649", size = 32284, upload-time = "2026-03-26T10:23:14.151Z" }, ] [[package]] @@ -2304,11 +2304,11 @@ wheels = [ [[package]] name = "jsonpointer" -version = "3.1.0" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/bf/9ecc036fbc15cf4153ea6ed4dbeed31ef043f762cccc9d44a534be8319b0/jsonpointer-3.1.0.tar.gz", hash = "sha256:f9b39abd59ba8c1de8a4ff16141605d2a8dacc4dd6cf399672cf237bfe47c211", size = 9000, upload-time = "2026-03-20T21:47:09.982Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/25/cebb241a435cbf4626b5ea096d8385c04416d7ca3082a15299b746e248fa/jsonpointer-3.1.0-py3-none-any.whl", hash = "sha256:f82aa0f745001f169b96473348370b43c3f581446889c41c807bab1db11c8e7b", size = 7651, upload-time = "2026-03-20T21:47:08.792Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] [[package]] @@ -2835,11 +2835,11 @@ wheels = [ [[package]] name = "marshmallow" -version = "4.2.2" +version = "4.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/03/261af5efb3d3ce0e2db3fd1e11dc5a96b74a4fb76e488da1c845a8f12345/marshmallow-4.2.2.tar.gz", hash = "sha256:ba40340683a2d1c15103647994ff2f6bc2c8c80da01904cbe5d96ee4baa78d9f", size = 221404, upload-time = "2026-02-04T15:47:03.401Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/ed/ecdd3a8259680dc9626ed5971b110c9be560962e11f7b0319b45ec2092b1/marshmallow-4.2.3.tar.gz", hash = "sha256:3e3fef6b3603721a25a723b8caedfa488369bddaf9bc03b40b9442c90aebd22b", size = 222932, upload-time = "2026-03-25T23:27:18.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/70/bb89f807a6a6704bdc4d6f850d5d32954f6c1965e3248e31455defdf2f30/marshmallow-4.2.2-py3-none-any.whl", hash = "sha256:084a9466111b7ec7183ca3a65aed758739af919fedc5ebdab60fb39d6b4dc121", size = 48454, upload-time = "2026-02-04T15:47:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/ce3278954d44903ccf8606c2a16e6b3ad8e10146c21de1b7b9b0f5812b23/marshmallow-4.2.3-py3-none-any.whl", hash = "sha256:c84fd89817ecea690bde1eb925036070e5e9883148217585adc56d5cfbc082b8", size = 48876, upload-time = "2026-03-25T23:27:16.907Z" }, ] [[package]] @@ -4013,7 +4013,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4971,14 +4971,14 @@ wheels = [ [[package]] name = "redis" -version = "7.3.0" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081, upload-time = "2026-03-06T18:18:16.287Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379, upload-time = "2026-03-06T18:18:14.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] [[package]] @@ -4997,7 +4997,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -5005,9 +5005,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -5401,15 +5401,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.55.0" +version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, ] [[package]] @@ -5882,56 +5882,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -6118,28 +6118,28 @@ wheels = [ [[package]] name = "uv" -version = "0.10.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/b7/6a27678654caa7f2240d9c5be9bd032bfff90a58858f0078575e7a9b6d9f/uv-0.10.12.tar.gz", hash = "sha256:fa722691c7ae5c023778ad0b040ab8619367bcfe44fd0d9e05a58751af86cdf8", size = 3988720, upload-time = "2026-03-19T21:50:41.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/aa/dde1b7300f8e924606ab0fe192aa25ca79736c5883ee40310ba8a5b34042/uv-0.10.12-py3-none-linux_armv6l.whl", hash = "sha256:7099bdefffbe2df81accad52579657b8f9f870170caa779049c9fd82d645c9b3", size = 22662810, upload-time = "2026-03-19T21:50:43.108Z" }, - { url = "https://files.pythonhosted.org/packages/5c/90/4fd10d7337a084847403cdbff288395a6a12adbaaac975943df4f46c2d31/uv-0.10.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e0f0ef58f0ba6fbfaf5f91b67aad6852252c49b8f78015a2a5800cf74c7538d5", size = 21852701, upload-time = "2026-03-19T21:51:06.216Z" }, - { url = "https://files.pythonhosted.org/packages/ce/db/c41ace81b8ef5d5952433df38e321c0b6e5f88ce210c508b14f84817963f/uv-0.10.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:551f799d53e397843b6cde7e3c61de716fb487da512a21a954b7d0cbc06967e0", size = 20454594, upload-time = "2026-03-19T21:50:53.693Z" }, - { url = "https://files.pythonhosted.org/packages/5d/07/a990708c5ba064b4eb1a289f1e9c484ebf5c1a0ea8cad049c86625f3b467/uv-0.10.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a5afe619e8a861fe4d49df8e10d2c6963de0dac6b79350c4832bf3366c8496cf", size = 22212546, upload-time = "2026-03-19T21:51:08.76Z" }, - { url = "https://files.pythonhosted.org/packages/b7/26/7f5ac4af027846c24bd7bf0edbd48b805f9e7daec145c62c632b5ce94e5f/uv-0.10.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8dc352c93a47a4760cf824c31c55ce26511af780481e8f67c796d2779acaa928", size = 22278457, upload-time = "2026-03-19T21:51:19.895Z" }, - { url = "https://files.pythonhosted.org/packages/02/00/c9043c73fb958482c9b42ad39ba81d1bd1ceffef11c4757412cb17f12316/uv-0.10.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd84379292e3c1a1bf0a05847c7c72b66bb581dccf8da1ef94cc82bf517efa7c", size = 22239751, upload-time = "2026-03-19T21:50:51.25Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d1/31fe74bf2a049446dd95213890ffed98f733d0f5e3badafec59164951608/uv-0.10.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ace05115bd9ee1b30d341728257fe051817c4c0a652c085c90d4bd4fb0bc8f2", size = 23697005, upload-time = "2026-03-19T21:50:48.767Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9a/dd58ef59e622a1651e181ec5b7d304ae482e591f28a864c474d09ea00aff/uv-0.10.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be85acae8f31c68311505cd96202bad43165cbd7be110c59222f918677e93248", size = 24453680, upload-time = "2026-03-19T21:51:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/09/26/b5920b43d7c91e720b72feaf81ea8575fa6188b626607695199fb9a0b683/uv-0.10.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2bb5893d79179727253e4a283871a693d7773c662a534fb897aa65496aa35765", size = 23570067, upload-time = "2026-03-19T21:51:13.976Z" }, - { url = "https://files.pythonhosted.org/packages/8d/42/139e68d7d92bb90a33b5e269dbe474acb00b6c9797541032f859c5bf4c4d/uv-0.10.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101481a1f48db6becf219914a591a588c0b3bfd05bef90768a5d04972bd6455e", size = 23498314, upload-time = "2026-03-19T21:50:36.104Z" }, - { url = "https://files.pythonhosted.org/packages/0c/75/40b237d005e4cdef9f960c215d3e2c0ab4f459ca009c3800cdcb07fbaa1d/uv-0.10.12-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:384b7f36a1ae50efe5f50fe299f276a83bf7acc8b7147517f34e27103270f016", size = 22314017, upload-time = "2026-03-19T21:50:56.45Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c3/e65a6d795d5baf6fc113ff764650cc6dd792d745ff23f657e4c302877365/uv-0.10.12-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:2c21e1b36c384f75dd3fd4a818b04871158ce115efff0bb4fdcd18ba2df7bd48", size = 23321597, upload-time = "2026-03-19T21:50:39.012Z" }, - { url = "https://files.pythonhosted.org/packages/65/ad/00f561b90b0ddfd1d591a78299fdeae68566e9cf82a4913548e4b700afef/uv-0.10.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:006812a086fce03d230fc987299f7295c7a73d17a1f1c17de1d1f327826f8481", size = 23336447, upload-time = "2026-03-19T21:50:58.764Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6e/ddf50c9ad12cffa99dbb6d1ab920da8ba95e510982cf53df3424e8cbc228/uv-0.10.12-py3-none-musllinux_1_1_i686.whl", hash = "sha256:2c5dfc7560453186e911c8c2e4ce95cd1c91e1c5926c3b34c5a825a307217be9", size = 22855873, upload-time = "2026-03-19T21:51:01.13Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9a/31a9c2f939849e56039bbe962aef6fb960df68c31bebd834d956876decfc/uv-0.10.12-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b9ca1d264059cb016c853ebbc4f21c72d983e0f347c927ca29e283aec2f596cf", size = 23675276, upload-time = "2026-03-19T21:51:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/81/83/9225e3032f24fcb3b80ff97bbd4c28230de19f0f6b25dbad3ba6efda035e/uv-0.10.12-py3-none-win32.whl", hash = "sha256:cca36540d637c80d11d8a44a998a068355f0c78b75ec6b0f152ecbf89dfdd67b", size = 21739726, upload-time = "2026-03-19T21:50:46.155Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9c/1954092ce17c00a8c299d39f8121e4c8d60f22a69c103f34d8b8dc68444d/uv-0.10.12-py3-none-win_amd64.whl", hash = "sha256:76ebe11572409dfbe20ec25a823f9bc8781400ece5356aa33ec44903af7ec316", size = 24219668, upload-time = "2026-03-19T21:51:03.591Z" }, - { url = "https://files.pythonhosted.org/packages/37/92/9ca420deb5a7b6716d8746e1b05eb2c35a305ff3b4aa57061919087d82dd/uv-0.10.12-py3-none-win_arm64.whl", hash = "sha256:6727e3a0208059cd4d621684e580d5e254322dacbd806e0d218360abd0d48a68", size = 22544602, upload-time = "2026-03-19T21:51:22.678Z" }, +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/e9/691eb77e5e767cdec695db3f91ec259bbb66f9af7c86a8dbe462ef72a120/uv-0.11.1.tar.gz", hash = "sha256:8aa7e4983fabb06d0ba58e8b8c969d568ce495ad5f2f0426af97b55720f0dee1", size = 4007244, upload-time = "2026-03-24T23:14:18.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/f9/a95c44fba785c27a966087154a8f6825774d49a38b3c5cd35f80e07ca5ca/uv-0.11.1-py3-none-linux_armv6l.whl", hash = "sha256:424b5b412d37838ea6dc11962f037be98b92e83c6ec755509e2af8a4ca3fbf2a", size = 23320598, upload-time = "2026-03-24T23:13:44.998Z" }, + { url = "https://files.pythonhosted.org/packages/5d/de/b7e24956a2508debf2addefcad93c72165069370f914d90db6264e0cf96a/uv-0.11.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c2133b0532af0217bf252d981bded8bff0c770f174f91f20655f88705f28c03f", size = 22832732, upload-time = "2026-03-24T23:13:33.677Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/1ac91bc704c22a427a44262f09e208ae897817a856d0e8dc0d60e4032e92/uv-0.11.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1a7b74e5a15b9bc6e61ce807adeca5a2807f557d3f06a5586de1da309d844c1d", size = 21406409, upload-time = "2026-03-24T23:14:32.231Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/f767701e1160538d25ee6c1d49ce1e72442970b6658365afdd57339d10e0/uv-0.11.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:fb1f32ec6c7dffb7ae71afaf6bf1defca0bd20a73a25e61226210c0a3e8bb13d", size = 23154066, upload-time = "2026-03-24T23:14:07.334Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/d2cfa3571557ba68ffd530656b1d7159fe59a6b01be94595351b1eec1c29/uv-0.11.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:0d5cf3c1c96f8afd67072d80479a58c2d69471916bac4ac36cc55f2aa025dc8e", size = 22922490, upload-time = "2026-03-24T23:13:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/59/3c/68119f555b2ec152235951cc9aa0f40006c5f03d17c98adaab6a3d36d42b/uv-0.11.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5829a254c64b19420b9e48186182d162b01f8da0130e770cbb8851fd138bb820", size = 22923054, upload-time = "2026-03-24T23:14:03.595Z" }, + { url = "https://files.pythonhosted.org/packages/70/ce/0df944835519372b1d698acaa388baa874cf69a6183b5f0980cb8855b81a/uv-0.11.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4259027e80f4dcc9ae3dceddcd5407173d334484737166fc212e96bb760d6ea", size = 24576177, upload-time = "2026-03-24T23:14:25.263Z" }, + { url = "https://files.pythonhosted.org/packages/db/04/0076335413c618fe086e5a4762103634552e638a841e12a4bb8f5137d710/uv-0.11.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6169eb49d1d2b5df7a7079162e1242e49ad46c6590c55f05b182fa526963763", size = 25207026, upload-time = "2026-03-24T23:14:11.579Z" }, + { url = "https://files.pythonhosted.org/packages/bb/57/79c0479e12c2291ad9777be53d813957fa38283975b708eead8e855ba725/uv-0.11.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c96a7310a051b1013efffe082f31d718bce0538d4abc20a716d529bf226b7c44", size = 24393748, upload-time = "2026-03-24T23:13:48.553Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/9ef73c8b6ef04b0cead7d8f1547034568e3e58f3397b55b83167e587f84a/uv-0.11.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ccc438dbb905240a3630265feb25be1bda61656ec7c32682a83648a686f4aa", size = 24518525, upload-time = "2026-03-24T23:13:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a3/035c7c2feb2139efb5d70f2e9f68912c34f7d92ee2429bacd708824483bb/uv-0.11.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44f528ba3d66321cea829770982cccb14af142203e4e19d00ff0c23b28e3cd33", size = 23270167, upload-time = "2026-03-24T23:13:51.937Z" }, + { url = "https://files.pythonhosted.org/packages/25/59/2dd782b537bfd1e41cb06de4f4a529fe2f9bd10034fb3fcce225ec86c1a5/uv-0.11.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4fcc3d5fdea24181d77e7765bf9d16cdd9803fd524820c62c66f91b2e2644d5b", size = 24011976, upload-time = "2026-03-24T23:13:37.402Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/9983e6f31d495cc548f1e211cab5b89a3716f406a2d9d8134b8245ec103c/uv-0.11.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5de9e43a32079b8d57093542b0cd8415adba5ed9944fa49076c0927f3ff927e1", size = 24029605, upload-time = "2026-03-24T23:14:28.819Z" }, + { url = "https://files.pythonhosted.org/packages/19/dc/9c59e803bfc1b9d6c4c4b7374689c688e9dc0a1ecc2375399d3a59fd4a58/uv-0.11.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f13ae98a938effae5deb587a63e7e42f05d6ba9c1661903ef538e4e87b204f8c", size = 23702811, upload-time = "2026-03-24T23:14:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/7d/77/b1cbfdac0b2dd3e7aa420e9dad1abe8badb47eabd8741a9993586b14f8dc/uv-0.11.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:57d38e8b6f6937e1521da568adf846bb89439c73e146e89a8ab2cfe7bb15657a", size = 24714239, upload-time = "2026-03-24T23:13:29.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d3/94917751acbbb5e053cb366004ae8be3c9664f82aef7de54f55e38ec15cb/uv-0.11.1-py3-none-win32.whl", hash = "sha256:36f4552b24acaa4699b02baeb1bb928202bb98d426dcc5041ab7ebae082a6430", size = 22404606, upload-time = "2026-03-24T23:13:55.614Z" }, + { url = "https://files.pythonhosted.org/packages/aa/87/8dadfe03944a4a493cd58b6f4f13e5181069a0048aeb2fae7da2c587a542/uv-0.11.1-py3-none-win_amd64.whl", hash = "sha256:d6a1c4cdb1064e9ceaa59e89a7489dd196222a0b90cfb77ca37a909b5e024ea0", size = 24850092, upload-time = "2026-03-24T23:14:15.186Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/dad559273df0c8263533afa4a28570cf6804272f379df9830b528a9cf8bc/uv-0.11.1-py3-none-win_arm64.whl", hash = "sha256:3bc9632033c7a280342f9b304bd12eccb47d6965d50ea9ee57ecfaf4f1f393c4", size = 23376127, upload-time = "2026-03-24T23:13:59.59Z" }, ] [[package]] From 0aba6574b81b237da2d6fe204f3fbb4de3af45ae Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 16:42:02 +0000 Subject: [PATCH 33/41] complete tests --- .../common/server/zurich_lockin_amplifier.py | 11 +++++------ src/sm_bluesky/common/utils/decorators.py | 14 +++++--------- ...2_server.py => test_zurich_lockin_amplifier.py} | 8 ++++++++ 3 files changed, 18 insertions(+), 15 deletions(-) rename tests/common/server/{test_hf2_server.py => test_zurich_lockin_amplifier.py} (97%) diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py index 0da445ed..0f57d66a 100644 --- a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -167,6 +167,11 @@ def _get_combined_data(self, duration: float = 0.1) -> None: response = f"{x:e}, {y:e}, {theta:f}, {static:e}, {r:e}" self._send_response(response.encode()) + @auto_type_cast + def _setup_scope_cmd(self, freq: float = 5.0, length: int = 4096, channel: int = 0): + self._setup_scope(freq, length, channel) + self._send_response(b"Scope configured") + @auto_type_cast def _set_current_range(self, value: float): # current range is in multiple of 10 between 1e-9 to 1e-2 @@ -184,12 +189,6 @@ def _set_ref_output(self, value: int): path="sigouts/0/enables/1", value=value, response_msg=b"Output set to" ) - @auto_type_cast - def _setup_scope_cmd(self, freq: float = 5.0, length: int = 4096, channel: int = 0): - self._setup_scope(freq, length, channel) - self._send_response(b"Scope configured") - - # Add command mappings... def _auto_voltage_range(self): self._set_node( path="sigins/0/autorange", value=1, response_msg=b"Auto voltage triggered" diff --git a/src/sm_bluesky/common/utils/decorators.py b/src/sm_bluesky/common/utils/decorators.py index 582156b0..223c9499 100644 --- a/src/sm_bluesky/common/utils/decorators.py +++ b/src/sm_bluesky/common/utils/decorators.py @@ -1,7 +1,7 @@ import inspect from collections.abc import Callable from functools import wraps -from typing import Any, ParamSpec, TypeVar, cast, get_type_hints +from typing import Any, TypeVar, cast, get_type_hints from bluesky.utils import MsgGenerator @@ -49,22 +49,18 @@ def add_extra_names_to_meta( return md -P = ParamSpec("P") -R = TypeVar("R") - - -def auto_type_cast(func: Callable[P, R]) -> Callable[P, R]: +def auto_type_cast(func: Callable) -> Callable: """ Casts positional byte arguments to hinted types. Skips 'self' and handles empty strings gracefully. """ @wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + def wrapper(*args) -> Callable: sig = inspect.signature(func) hints = get_type_hints(func) - bound_args = sig.bind(*args, **kwargs) + bound_args = sig.bind(*args) bound_args.apply_defaults() for name, value in bound_args.arguments.items(): @@ -77,6 +73,6 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: except (ValueError, UnicodeDecodeError) as e: raise TypeError(f"Argument '{name}' casting failed: {e}") from e - return func(*bound_args.args, **bound_args.kwargs) + return func(*bound_args.args) return wrapper diff --git a/tests/common/server/test_hf2_server.py b/tests/common/server/test_zurich_lockin_amplifier.py similarity index 97% rename from tests/common/server/test_hf2_server.py rename to tests/common/server/test_zurich_lockin_amplifier.py index 51ba91aa..73538973 100644 --- a/tests/common/server/test_hf2_server.py +++ b/tests/common/server/test_zurich_lockin_amplifier.py @@ -298,3 +298,11 @@ def test_get_combined_data( mock_server._get_single_scope_shot.assert_called_once() response = f"{1:e}, {2:e}, {4:f}, {5:e}, {3:e}".encode() mock_server._send_response.assert_called_once_with(response) + + +def test_setup_scope_cmd(mock_server: HF2Server): + mock_server._setup_scope = MagicMock() + mock_server._send_response = MagicMock() + mock_server._setup_scope_cmd() + assert mock_server._send_response(b"Scope configured") + mock_server._setup_scope.assert_called_once() From 292918b531782bf4d1b3fa601bfffe2a1d99f9fa Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 17:26:16 +0000 Subject: [PATCH 34/41] change to use time_outcontext instead --- .../server/abstract_instrument_server.py | 34 +++++-------------- .../server/test_abstract_instrument_server.py | 29 ++-------------- 2 files changed, 11 insertions(+), 52 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 600b840e..771f752d 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,9 +1,8 @@ -import ctypes import socket -import threading from abc import ABC, abstractmethod from collections.abc import Callable from contextlib import contextmanager +from time import time from sm_bluesky.log import LOGGER, logging @@ -73,30 +72,15 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): LOGGER.info(f"Client {addr} disconnected. Server ready.") @contextmanager - def _hardware_watch(self, seconds: int = 60): - """Context manager to interrupt hardware calls that took too long.""" - target_tid = threading.get_ident() - stop_event = threading.Event() - - def trigger_timeout(): - if not stop_event.wait(timeout=seconds): - # Inject TimeoutError and kill the thread - res = ctypes.pythonapi.PyThreadState_SetAsyncExc( - ctypes.c_long(target_tid), ctypes.py_object(TimeoutError) - ) - if res > 1: - # If it affected more than one thread, undo it! - ctypes.pythonapi.PyThreadState_SetAsyncExc( - ctypes.c_long(target_tid), None - ) - - monitor = threading.Thread(target=trigger_timeout, daemon=True) - monitor.start() - + def _timeout_context(self, seconds: int): + """ + Provides a deadline for hardware operations. + """ + deadline = time() + seconds try: - yield + yield deadline finally: - stop_event.set() + pass def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" @@ -171,7 +155,7 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - with self._hardware_watch(seconds=60): + with self._timeout_context(seconds=60): arg_list = args.split(b"\t") if args else [] handler(*arg_list) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 92a69270..600a6739 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,5 +1,4 @@ import socket -import time from unittest.mock import MagicMock, patch import pytest @@ -222,8 +221,8 @@ def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): ) -def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): - """Tests that a TimeoutError (hardware_Watch trip) is caught and reported +def test__timeout_context_timeout(mock_instrument: AbstractInstrumentServer): + """Tests that a TimeoutError (hardware Watch trip) is caught and reported correctly.""" cmd = b"getData" @@ -236,27 +235,3 @@ def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): args_called, _ = mock_instrument._error_helper.call_args assert "hardware not responding" in args_called[0].lower() assert isinstance(args_called[1], TimeoutError) - - -def test_hardware_watch_thread_lifecycle(mock_instrument): - """Verifies that the watchdog starts a monitor thread and cleans up.""" - with patch("threading.Thread") as mock_thread: - mock_thread_instance = mock_thread.return_value - - with mock_instrument._hardware_watch(seconds=10): - mock_thread.assert_called_once() - mock_thread_instance.start.assert_called_once() - - -def test_hardware_watch_injection(mock_instrument): - cmd = b"hang" - - def hanging_command(): - time.sleep(0.1) - - mock_instrument._command_registry[cmd] = hanging_command - mock_instrument._error_helper = MagicMock() - - with pytest.raises(TimeoutError): - with mock_instrument._hardware_watch(seconds=0.01): - hanging_command() From d43d08ceaab90849609be093e7cfd63b1341eaef Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 27 Mar 2026 13:51:50 +0000 Subject: [PATCH 35/41] update _get_lockin_data to use polling --- .../common/server/zurich_lockin_amplifier.py | 38 ++++++++++-------- .../server/test_zurich_lockin_amplifier.py | 40 +++++++++++++------ 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py index 0f57d66a..2878c4ef 100644 --- a/src/sm_bluesky/common/server/zurich_lockin_amplifier.py +++ b/src/sm_bluesky/common/server/zurich_lockin_amplifier.py @@ -1,4 +1,4 @@ -from time import sleep, time +from time import sleep from typing import Literal import numpy as np @@ -131,22 +131,26 @@ def _get_single_scope_shot(self) -> float: def _get_lockin_data(self, duration: float) -> tuple[float, float, float, float]: """Averages demodulator data over a specific duration.""" - x, y = [], [] - start_time = time() - end_time = start_time + duration - start_avg_time = start_time + (duration * 0.5) - - # Wait for stabilization - while time() < start_avg_time: - sleep(0.01) - - # Collect samples - while time() < end_time: - sample = self.device.getSample(f"/{self.device_id}/demods/0/sample") - x.append(sample["x"]) - y.append(sample["y"]) - - avg_x, avg_y = float(np.mean(x)), float(np.mean(y)) + + path = f"/{self.device_id}/demods/0/sample" + self.device.subscribe(path) + try: + poll_results = self.device.poll( + recording_time_s=duration, timeout_ms=500, flat=True + ) + if path in poll_results: + samples = poll_results[path] + avg_x = float(np.mean(samples["x"])) + avg_y = float(np.mean(samples["y"])) + else: + LOGGER.warning( + f"Poll returned no data for {path}, falling back to getSample" + ) + sample = self.device.getSample(path) + avg_x, avg_y = float(sample["x"]), float(sample["y"]) + finally: + self.device.unsubscribe(path) + r = float(np.abs(avg_x + 1j * avg_y)) theta = float(np.rad2deg(np.arctan2(avg_y, avg_x))) return avg_x, avg_y, r, theta diff --git a/tests/common/server/test_zurich_lockin_amplifier.py b/tests/common/server/test_zurich_lockin_amplifier.py index 73538973..510a60be 100644 --- a/tests/common/server/test_zurich_lockin_amplifier.py +++ b/tests/common/server/test_zurich_lockin_amplifier.py @@ -163,27 +163,41 @@ def test_get_single_scope_shot_frequncy_error(mock_server: HF2Server): mock_server._get_single_scope_shot() -@patch("sm_bluesky.common.server.zurich_lockin_amplifier.time") -@patch("sm_bluesky.common.server.zurich_lockin_amplifier.sleep") -def test_get_lockin_data_averaging( - mock_sleep: MagicMock, mock_time: MagicMock, mock_server: HF2Server -): +def test_get_lockin_data_averaging_polling_failed(mock_server: HF2Server): mock_server._device = MagicMock() - mock_time.side_effect = [100, 100.2, 100.4, 100.7, 100.8, 100.9, 102] - mock_server._device.getSample.side_effect = [ - {"x": 1.0, "y": 2.0}, - {"x": 3.0, "y": 4.0}, - ] + mock_server._device.poll.return_value = {} + mock_server._device.getSample.return_value = {"x": 1.0, "y": 2.0} x, y, r, theta = mock_server._get_lockin_data(1.0) + assert x == 1.0 + assert y == 2.0 + assert r == pytest.approx(2.236, rel=1e-4) + + assert theta == pytest.approx(63.434, rel=1e-4) + assert mock_server._device.getSample.call_count == 1 + + +def test_get_lockin_data_averaging_polling(mock_server: HF2Server): + path = f"/{mock_server.device_id}/demods/0/sample" + mock_server._device = MagicMock() + mock_server._device.poll.side_effect = [ + {path: {"x": [1, 3.0], "y": [2, 4]}}, + ] + duration = 1.0 + x, y, r, theta = mock_server._get_lockin_data(duration) + assert x == 2.0 assert y == 3.0 assert r == pytest.approx(3.60555, rel=1e-4) assert theta == pytest.approx(56.3099, rel=1e-4) - mock_sleep.assert_called_with(0.01) - assert mock_server._device.getSample.call_count == 2 + mock_server._device.subscribe.assert_called_once_with(path) + mock_server._device.poll.assert_called_once_with( + recording_time_s=duration, timeout_ms=500, flat=True + ) + mock_server._device.unsubscribe.assert_called_once_with(path) + assert mock_server._device.getSample.call_count == 0 def test_get_lockin_data_fail(mock_server: HF2Server): @@ -288,7 +302,7 @@ def test_get_combined_data( mock_server: HF2Server, ): duration = b"0.2" - mock_server._get_lockin_data = MagicMock(return_value={1, 2, 3, 4}) + mock_server._get_lockin_data = MagicMock(return_value=(1, 2, 3, 4)) mock_server._get_single_scope_shot = MagicMock(return_value=5) mock_server._send_response = MagicMock() mock_server._get_combined_data(duration) # type: ignore From 2e7a2cbc375530e39ec6db74f9c3c84891a749f3 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 08:53:30 +0000 Subject: [PATCH 36/41] add docs --- docs/how-to/5_Zurick_lockin_amplifier.md | 76 +++++++++++++++++++++++ src/sm_bluesky/common/utils/decorators.py | 7 ++- tests/common/utils/test_decorators.py | 2 +- 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 docs/how-to/5_Zurick_lockin_amplifier.md diff --git a/docs/how-to/5_Zurick_lockin_amplifier.md b/docs/how-to/5_Zurick_lockin_amplifier.md new file mode 100644 index 00000000..b1ff74b7 --- /dev/null +++ b/docs/how-to/5_Zurick_lockin_amplifier.md @@ -0,0 +1,76 @@ +# Zurick lockin amplifier(HF2Server) + +A TCP-based gateway for Zurich Instruments HF2 series lock-in amplifiers. This server allows remote clients to control hardware nodes and acquire averaged data via a simple Tab-Separated Protocol. + +## 📡 Network Protocol + +The server uses a **Tab-Separated Protocol** over TCP. + +**Request Format:** +`command` + `\t` + `arg1` + `\t` + `arg2` ... + `\n` + +**Response Format:** +* `1\t[Data]\n` : **Success**. +* `0\t[Error Message]\n` : **Failure**. + +# HF2Server Reference + +A TCP-based gateway for Zurich Instruments HF2 series lock-in amplifiers. This server allows remote clients to control hardware nodes and acquire averaged data via a simple Tab-Separated Protocol. + +## 📡 Network Protocol + +**Request Format:** `command` + `\t` + `arg1` + `\t` + `arg2` ... + `\n` +**Response Format:** `1\t[Data]\n` (Success) or `0\t[Error Message]\n` (Failure) + +## 📖 Complete Command Reference + +| Command | Arguments | Description | +| :--- | :--- | :--- | +| **Data Acquisition** | | | +| `getData` | `duration` (float) | Returns: `x, y, theta, scope_mean, r` | +| `setupScope` | `freq`, `len`, `ch` | Configures Scope for single shot (Time, Length, Input Select). | +| **Oscillator & Output** | | | +| `setRefFreq` | `val` (float) | Sets Lockin reference Frequency (Hz). | +| `setRefV` | `val` (float) | Sets reference voltage Amplitude (Vpk). | +| `setRefVoff` | `val` (float) | Sets Signal voltage Offset (V). | +| `setsRefOutSwitch`| `state` (0/1) | Enables (1) or Disables (0) Signal Output 0. | +| **Demodulator Settings** | | | +| `setTimeConstant` | `val` (float) | Sets Lockin (low pass) Time Constant (s). | +| `setDataRate` | `val` (float) | Sets Demodulator 0 Sample Rate (Hz). | +| `setsRefHarm` | `val` (int) | Sets Demodulator Harmonic. | +| **Input & Autorange** | | | +| `setCurrentInRange`| `val` (float) | Sets Current Input Range (Powers of 10). | +| `autoCurrentInRange`| None | Triggers Autorange for Current Input 0. | +| `autoVoltageInRange`| None | Triggers Autorange for Signal Input 0. | +| **System** | | | +| `ping` | None | Returns `1\t` if server is alive. | +| `connect_hardware`| None | Re-establishes connection to ZI Data Server. | +| `disconnect_hardware`| None | Safely disconnects from hardware. | +| `shutdown` | None | Stops the server and disconnects hardware. | +## 💻 Example Usage + +### 1. Start the Server +```python +from sm_bluesky.common.server import HF2Server + +server = HF2Server( + host="0.0.0.0", + port=7891, + device_id="dev4206", + hf2_ip="172.23.110.84" +) +server.start() +``` +### 2. Client + +import socket +```python + +def query_hf2(command): + with socket.create_connection(("localhost", 7891)) as sock: + sock.sendall(f"{command}\n".encode()) + return sock.recv(1024).decode() + +# Example: Get 0.5s of averaged data +print(query_hf2("getData\t0.5")) +``` diff --git a/src/sm_bluesky/common/utils/decorators.py b/src/sm_bluesky/common/utils/decorators.py index 223c9499..f29ec75c 100644 --- a/src/sm_bluesky/common/utils/decorators.py +++ b/src/sm_bluesky/common/utils/decorators.py @@ -67,7 +67,12 @@ def wrapper(*args) -> Callable: if isinstance(value, bytes) and name in hints: target_type = hints[name] try: - str_val = value.decode("utf-8") + str_val = value.decode("utf-8").strip() + if not str_val: + default_val = sig.parameters[name].default + if default_val is not inspect.Parameter.empty: + bound_args.arguments[name] = default_val + continue if target_type in (int, float, str): bound_args.arguments[name] = target_type(str_val) except (ValueError, UnicodeDecodeError) as e: diff --git a/tests/common/utils/test_decorators.py b/tests/common/utils/test_decorators.py index fad855a7..7203e1b3 100644 --- a/tests/common/utils/test_decorators.py +++ b/tests/common/utils/test_decorators.py @@ -137,7 +137,7 @@ def test_cast_invalid(test_casting: TestCasting): [ ([b"5", b"6.5", b"hello"], (5, 6.5, "hello")), ([b"10", b"0.0", b"world"], (10, 0.0, "world")), - ([b"1", b"1.1"], (1, 1.1, "string")), + ([b"1", b"1.1", b""], (1, 1.1, "string")), ([b"1"], (1, 5, "string")), ], ) From ecf482ce3ac5cfb483526c1a6e28f54b9d814fda Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 11:36:16 +0000 Subject: [PATCH 37/41] add _check_timeout for sub class to check and raise timeout if it took too long --- .../server/abstract_instrument_server.py | 19 ++++++++++++++----- .../server/test_abstract_instrument_server.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 771f752d..ff7eb409 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -22,6 +22,8 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self._hardware_connected: bool = False self._server_socket: socket.socket self._conn: socket.socket | None = None + self._current_deadline: float | None = None + self._timeout_seconds: float = 60.0 self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET self._command_registry: dict[bytes, Callable] = { b"connect_hardware": self.connect_hardware, @@ -72,15 +74,16 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): LOGGER.info(f"Client {addr} disconnected. Server ready.") @contextmanager - def _timeout_context(self, seconds: int): + def _timeout_context(self, seconds: float): """ Provides a deadline for hardware operations. """ - deadline = time() + seconds + self._timeout_seconds = seconds + self._current_deadline = time() + seconds try: - yield deadline + yield self._current_deadline finally: - pass + self._current_deadline = None def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" @@ -155,7 +158,7 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - with self._timeout_context(seconds=60): + with self._timeout_context(seconds=self._timeout_seconds): arg_list = args.split(b"\t") if args else [] handler(*arg_list) @@ -179,6 +182,12 @@ def _error_helper( LOGGER.log(level=level, msg=err_msg) self._send_error(err_msg) + def _check_timeout(self, context: str = "Hardware operation"): + """Raises TimeoutError if the current operation has exceeded its deadline.""" + if hasattr(self, "_current_deadline") and self._current_deadline is not None: + if time() > self._current_deadline: + raise TimeoutError(f"{context} exceeded {self._timeout_seconds}s limit") + @abstractmethod def connect_hardware(self) -> bool: """Establishes connection to the specific hardware device.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 600a6739..501b1e14 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,4 +1,5 @@ import socket +from time import sleep from unittest.mock import MagicMock, patch import pytest @@ -235,3 +236,21 @@ def test__timeout_context_timeout(mock_instrument: AbstractInstrumentServer): args_called, _ = mock_instrument._error_helper.call_args assert "hardware not responding" in args_called[0].lower() assert isinstance(args_called[1], TimeoutError) + + +def test_check_timeout_raises_when_expired(mock_instrument: AbstractInstrumentServer): + with mock_instrument._timeout_context(seconds=0.1): + sleep(0.15) + with pytest.raises(TimeoutError, match="Test Operation exceeded 0.1s limit"): + mock_instrument._check_timeout("Test Operation") + + +def test_check_timeout_passes_when_valid(mock_instrument: AbstractInstrumentServer): + """Verify that _check_timeout does nothing if time remains.""" + with mock_instrument._timeout_context(seconds=10): + try: + assert mock_instrument._current_deadline is not None + mock_instrument._check_timeout("Quick Task") + except TimeoutError: + pytest.fail("TimeoutError raised unexpectedly") + assert mock_instrument._current_deadline is None From 0920c79eb79d56f818b1a9867f6ee6538e5d0716 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 11:42:00 +0000 Subject: [PATCH 38/41] add docs --- .../4_Use_abstract_instrument_server.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/how-to/4_Use_abstract_instrument_server.md diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_Use_abstract_instrument_server.md new file mode 100644 index 00000000..519b9c3b --- /dev/null +++ b/docs/how-to/4_Use_abstract_instrument_server.md @@ -0,0 +1,60 @@ +# AbstractInstrumentServer + +A TCP server framework designed for interfacing with scientific instruments. This base class handles the network management, socket lifecycles, and command buffering, allowing you to focus exclusively on hardware-specific logic. + +## Features + +* **Connection Lifecycle Management:** Automatic handling of client connections and disconnections via Python context managers. +* **Command Dispatcher:** Built-in registry for mapping byte-string commands (e.g., `b"move"`) to Python methods. +* **Buffered Parsing:** Correctly handles TCP fragmentation by buffering incoming data until a newline (`\n`) is reached. +* **Timeout Safety:** Includes a deadline-based timeout context to prevent hardware hangs from locking the server loop. +* **Standardized Logging:** Integrated with `sm_bluesky.log` for consistent tracking of server events and errors. + +--- + +## Communication Protocol + +The server communicates using a simple **Tab-Separated Value (TSV)** format over a raw TCP stream. + +### Request Format (Client → Server) +Commands must be newline-terminated. +`COMMAND` + `\t` + `ARG1` + `\t` + `ARG2...` + `\n` + +### Response Format (Server → Client) +* **Success:** `1` + `\t` + `[Optional Data]` + `\n` +* **Error:** `0` + `\t` + `[Error Message]` + `\n` + +--- + +## Implementation Guide + +To use this framework, create a subclass and implement the mandatory abstract methods. + +### 1. Define your Hardware Class +```python +from sm_bluesky.servers import AbstractInstrumentServer + +class MyMotorServer(AbstractInstrumentServer): + def __init__(self, host, port): + super().__init__(host, port) + # Add custom hardware commands to the registry + self._command_registry[b"move_abs"] = self.move_absolute + + def connect_hardware(self) -> bool: + # Logic to initialize your physical device + print("Initializing Motor...") + return True + + def disconnect_hardware(self) -> None: + # Logic to safely shut down hardware + print("Parking Motor...") + + def move_absolute(self, position: bytes): + # Hardware logic: convert bytes arg to float + pos_mm = float(position) + print(f"Moving to {pos_mm}mm") + + # Periodic timeout check for long operations + self._check_timeout("Motor Move") + + self._send_response(b"Moved to " + position) From ee5d1f00406e58271f75699ee9b7a26f5edef9d1 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 11:58:36 +0000 Subject: [PATCH 39/41] correct docs --- .../4_Use_abstract_instrument_server.md | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_Use_abstract_instrument_server.md index 519b9c3b..3fdd2dfb 100644 --- a/docs/how-to/4_Use_abstract_instrument_server.md +++ b/docs/how-to/4_Use_abstract_instrument_server.md @@ -1,4 +1,4 @@ -# AbstractInstrumentServer +# Instrument Server (AbstractInstrumentServer) A TCP server framework designed for interfacing with scientific instruments. This base class handles the network management, socket lifecycles, and command buffering, allowing you to focus exclusively on hardware-specific logic. @@ -26,6 +26,16 @@ Commands must be newline-terminated. --- +## Default Methods + +| Command | Arguments | Description | +| :--- | :--- | :--- | + |`ping` | None | Returns `1\t` if server is alive. | +| `connect_hardware`| None | Re-establishes connection to hardware server. | +| `disconnect_hardware`| None | Safely disconnects from hardware. | +| `shutdown` | None | Stops the server and disconnects hardware. | + + ## Implementation Guide To use this framework, create a subclass and implement the mandatory abstract methods. @@ -38,7 +48,7 @@ class MyMotorServer(AbstractInstrumentServer): def __init__(self, host, port): super().__init__(host, port) # Add custom hardware commands to the registry - self._command_registry[b"move_abs"] = self.move_absolute + self._command_registry[b"move_abs"] = self._move_absolute def connect_hardware(self) -> bool: # Logic to initialize your physical device @@ -49,7 +59,7 @@ class MyMotorServer(AbstractInstrumentServer): # Logic to safely shut down hardware print("Parking Motor...") - def move_absolute(self, position: bytes): + def _move_absolute(self, position: bytes): # Hardware logic: convert bytes arg to float pos_mm = float(position) print(f"Moving to {pos_mm}mm") @@ -58,3 +68,10 @@ class MyMotorServer(AbstractInstrumentServer): self._check_timeout("Motor Move") self._send_response(b"Moved to " + position) +if __name__ == "__main__": + # Initialize and start the server + server = MyInstrumentServer("127.0.0.1", 5000) + try: + server.start() + except KeyboardInterrupt: + server.stop() From edf52ce43c17b7d25a538d0d1796806986c0b743 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 12:09:01 +0000 Subject: [PATCH 40/41] change docs to use auto_type_cast --- docs/how-to/4_Use_abstract_instrument_server.md | 6 ++++-- ...ck_lockin_amplifier.md => 4a_Zurick_lockin_amplifier.md} | 0 2 files changed, 4 insertions(+), 2 deletions(-) rename docs/how-to/{5_Zurick_lockin_amplifier.md => 4a_Zurick_lockin_amplifier.md} (100%) diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_Use_abstract_instrument_server.md index 3fdd2dfb..7790df74 100644 --- a/docs/how-to/4_Use_abstract_instrument_server.md +++ b/docs/how-to/4_Use_abstract_instrument_server.md @@ -43,6 +43,7 @@ To use this framework, create a subclass and implement the mandatory abstract me ### 1. Define your Hardware Class ```python from sm_bluesky.servers import AbstractInstrumentServer +from sm_bluesky.common.utils import auto_type_cast class MyMotorServer(AbstractInstrumentServer): def __init__(self, host, port): @@ -59,8 +60,9 @@ class MyMotorServer(AbstractInstrumentServer): # Logic to safely shut down hardware print("Parking Motor...") - def _move_absolute(self, position: bytes): - # Hardware logic: convert bytes arg to float + @auto_type_cast + def _move_absolute(self, position: float): + # auto_type_cast will try to map the bytes to to hinted types. pos_mm = float(position) print(f"Moving to {pos_mm}mm") diff --git a/docs/how-to/5_Zurick_lockin_amplifier.md b/docs/how-to/4a_Zurick_lockin_amplifier.md similarity index 100% rename from docs/how-to/5_Zurick_lockin_amplifier.md rename to docs/how-to/4a_Zurick_lockin_amplifier.md From 866a95a7bbba439c70b36e7b3e12bb769e171469 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 31 Mar 2026 09:21:24 +0000 Subject: [PATCH 41/41] correct typo in docs --- ...nstrument_server.md => 4_User_abstract_instrument_server.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/how-to/{4_Use_abstract_instrument_server.md => 4_User_abstract_instrument_server.md} (98%) diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_User_abstract_instrument_server.md similarity index 98% rename from docs/how-to/4_Use_abstract_instrument_server.md rename to docs/how-to/4_User_abstract_instrument_server.md index 3fdd2dfb..8de42bd1 100644 --- a/docs/how-to/4_Use_abstract_instrument_server.md +++ b/docs/how-to/4_User_abstract_instrument_server.md @@ -70,7 +70,7 @@ class MyMotorServer(AbstractInstrumentServer): self._send_response(b"Moved to " + position) if __name__ == "__main__": # Initialize and start the server - server = MyInstrumentServer("127.0.0.1", 5000) + server = MyMotorServer("127.0.0.1", 5000) try: server.start() except KeyboardInterrupt: