diff --git a/pyproject.toml b/pyproject.toml index 54a4a20..3eb7f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,44 +5,35 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "agentspeak>=0.2.2", - "colorlog>=6.10.1", - "fastapi[all]>=0.115.6", - "mlx-whisper>=0.4.3 ; sys_platform == 'darwin'", - "numpy>=2.3.3", - "openai-whisper>=20250625", - "pyaudio>=0.2.14", - "pydantic>=2.12.0", - "pydantic-settings>=2.11.0", - "pytest>=8.4.2", - "pytest-asyncio>=1.2.0", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", - "python-json-logger>=4.0.0", - "pyyaml>=6.0.3", - "pyzmq>=27.1.0", - "silero-vad>=6.0.0", - "sphinx>=7.3.7", - "sphinx-rtd-theme>=3.0.2", - "torch>=2.8.0", - "uvicorn>=0.37.0", + "agentspeak>=0.2.2", + "colorlog>=6.10.1", + "fastapi[all]>=0.115.6", + "mlx-whisper>=0.4.3 ; sys_platform == 'darwin'", + "numpy>=2.3.3", + "openai-whisper>=20250625", + "pyaudio>=0.2.14", + "pydantic>=2.12.0", + "pydantic-settings>=2.11.0", + "python-json-logger>=4.0.0", + "pyyaml>=6.0.3", + "pyzmq>=27.1.0", + "silero-vad>=6.0.0", + "sphinx>=7.3.7", + "sphinx-rtd-theme>=3.0.2", + "torch>=2.8.0", + "uvicorn>=0.37.0", ] [dependency-groups] dev = [ - "pre-commit>=4.3.0", - "ruff>=0.14.2", - "ruff-format>=0.3.0", -] -integration-test = [ - "soundfile>=0.13.1", -] -test = [ - "numpy>=2.3.3", - "pytest>=8.4.2", - "pytest-asyncio>=1.2.0", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", + "pre-commit>=4.3.0", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", + "soundfile>=0.13.1", + "ruff>=0.14.2", + "ruff-format>=0.3.0", ] [tool.pytest.ini_options] @@ -53,15 +44,15 @@ line-length = 100 [tool.ruff.lint] extend-select = [ - "E", # pycodestyle - "F", # pyflakes - "I", # isort (import sorting) - "UP", # pyupgrade (modernize code) - "B", # flake8-bugbear (common bugs) - "C4", # flake8-comprehensions (unnecessary comprehensions) + "E", # pycodestyle + "F", # pyflakes + "I", # isort (import sorting) + "UP", # pyupgrade (modernize code) + "B", # flake8-bugbear (common bugs) + "C4", # flake8-comprehensions (unnecessary comprehensions) ] ignore = [ - "E226", # spaces around operators - "E701", # multiple statements on a single line + "E226", # spaces around operators + "E701", # multiple statements on a single line ] diff --git a/src/control_backend/agents/bdi/bdi_core_agent/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent/bdi_core_agent.py index ccefb11..ec88282 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent/bdi_core_agent.py @@ -1,4 +1,5 @@ import asyncio +import copy import json from collections.abc import Iterable @@ -19,7 +20,8 @@ class BDICoreAgent(BaseAgent): super().__init__(name) self.asl_file = asl self.env = agentspeak.runtime.Environment() - self.actions = agentspeak.stdlib.actions + # Deep copy because we don't actually want to modify the standard actions globally + self.actions = copy.deepcopy(agentspeak.stdlib.actions) async def setup(self) -> None: self.logger.debug("Setup started.") diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 11d7999..bf131af 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -75,7 +75,7 @@ class Settings(BaseSettings): llm_settings: LLMSettings = LLMSettings() - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") settings = Settings() diff --git a/test/integration/api/endpoints/test_program_endpoint.py b/test/integration/api/endpoints/test_program_endpoint.py deleted file mode 100644 index f6bb261..0000000 --- a/test/integration/api/endpoints/test_program_endpoint.py +++ /dev/null @@ -1,125 +0,0 @@ -import json -from unittest.mock import AsyncMock - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from control_backend.api.v1.endpoints import program -from control_backend.schemas.program import Program - - -@pytest.fixture -def app(): - """Create a FastAPI app with the /program route and mock socket.""" - app = FastAPI() - app.include_router(program.router) - return app - - -@pytest.fixture -def client(app): - """Create a TestClient.""" - return TestClient(app) - - -def make_valid_program_dict(): - """Helper to create a valid Program JSON structure.""" - return { - "phases": [ - { - "id": "phase1", - "name": "basephase", - "nextPhaseId": "phase2", - "phaseData": { - "norms": [{"id": "n1", "name": "norm", "value": "be nice"}], - "goals": [ - {"id": "g1", "name": "goal", "description": "test goal", "achieved": False} - ], - "triggers": [ - { - "id": "t1", - "label": "trigger", - "type": "keyword", - "value": ["stop", "exit"], - } - ], - }, - } - ] - } - - -def test_receive_program_success(client): - """Valid Program JSON should be parsed and sent through the socket.""" - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - program_dict = make_valid_program_dict() - - response = client.post("/program", json=program_dict) - - assert response.status_code == 202 - assert response.json() == {"status": "Program parsed"} - - # Verify socket call - mock_pub_socket.send_multipart.assert_awaited_once() - args, kwargs = mock_pub_socket.send_multipart.await_args - - assert args[0][0] == b"program" - - sent_bytes = args[0][1] - sent_obj = json.loads(sent_bytes.decode()) - - expected_obj = Program.model_validate(program_dict).model_dump() - assert sent_obj == expected_obj - - -def test_receive_program_invalid_json(client): - """ - Invalid JSON (malformed) -> FastAPI never calls endpoint. - It returns a 422 Unprocessable Entity. - """ - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # FastAPI only accepts valid JSON bodies, so send raw string - response = client.post("/program", content="{invalid json}") - - assert response.status_code == 422 - mock_pub_socket.send_multipart.assert_not_called() - - -def test_receive_program_invalid_deep_structure(client): - """ - Valid JSON but schema invalid -> Pydantic throws validation error -> 422. - """ - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Missing "value" in norms element - bad_program = { - "phases": [ - { - "id": "phase1", - "name": "deepfail", - "nextPhaseId": "phase2", - "phaseData": { - "norms": [ - {"id": "n1", "name": "norm"} # INVALID: missing "value" - ], - "goals": [ - {"id": "g1", "name": "goal", "description": "desc", "achieved": False} - ], - "triggers": [ - {"id": "t1", "label": "trigger", "type": "keyword", "value": ["start"]} - ], - }, - } - ] - } - - response = client.post("/program", json=bad_program) - - assert response.status_code == 422 - mock_pub_socket.send_multipart.assert_not_called() diff --git a/test/integration/agents/actuation/test_robot_speech_agent.py b/test/unit/agents/actuation/test_robot_speech_agent.py similarity index 100% rename from test/integration/agents/actuation/test_robot_speech_agent.py rename to test/unit/agents/actuation/test_robot_speech_agent.py diff --git a/test/unit/agents/bdi/bdi_core_agent/behaviours/test_belief_setter.py b/test/unit/agents/bdi/bdi_core_agent/behaviours/test_belief_setter.py deleted file mode 100644 index 6d9e7ad..0000000 --- a/test/unit/agents/bdi/bdi_core_agent/behaviours/test_belief_setter.py +++ /dev/null @@ -1,211 +0,0 @@ -import json -import logging -from unittest.mock import AsyncMock, MagicMock, call - -import pytest -from control_backend.agents.bdi.bdi_core_agent.behaviours.belief_setter_behaviour import ( - BeliefSetterBehaviour, -) - -# Define a constant for the collector agent name to use in tests -COLLECTOR_AGENT_NAME = "belief_collector_agent" -COLLECTOR_AGENT_JID = f"{COLLECTOR_AGENT_NAME}@test" - - -@pytest.fixture -def mock_agent(mocker): - """Fixture to create a mock BDIAgent.""" - agent = MagicMock() - agent.bdi = MagicMock() - agent.jid = "bdi_agent@test" - return agent - - -@pytest.fixture -def belief_setter_behaviour(mock_agent, mocker): - """Fixture to create an instance of BeliefSetterBehaviour with a mocked agent.""" - # Patch the settings to use a predictable agent name - mocker.patch( - "control_backend.agents.bdi.bdi_core_agent." - "behaviours.belief_setter_behaviour.settings.agent_settings.bdi_belief_collector_name", - COLLECTOR_AGENT_NAME, - ) - - setter = BeliefSetterBehaviour() - setter.agent = mock_agent - # Mock the receive method, we will control its return value in each test - setter.receive = AsyncMock() - return setter - - -def create_mock_message(sender_node: str, body: str, thread: str) -> MagicMock: - """Helper function to create a configured mock message.""" - msg = MagicMock() - msg.sender.node = sender_node # MagicMock automatically creates nested mocks - msg.body = body - msg.thread = thread - return msg - - -@pytest.mark.asyncio -async def test_run_message_received(belief_setter_behaviour, mocker): - """ - Test that when a message is received, _process_message is called. - """ - # Arrange - msg = MagicMock() - belief_setter_behaviour.receive.return_value = msg - mocker.patch.object(belief_setter_behaviour, "_process_message") - - # Act - await belief_setter_behaviour.run() - - # Assert - belief_setter_behaviour._process_message.assert_called_once_with(msg) - - -def test_process_message_from_bdi_belief_collector_agent(belief_setter_behaviour, mocker): - """ - Test processing a message from the correct belief collector agent. - """ - # Arrange - msg = create_mock_message(sender_node=COLLECTOR_AGENT_NAME, body="", thread="") - mock_process_belief = mocker.patch.object(belief_setter_behaviour, "_process_belief_message") - - # Act - belief_setter_behaviour._process_message(msg) - - # Assert - mock_process_belief.assert_called_once_with(msg) - - -def test_process_message_from_other_agent(belief_setter_behaviour, mocker): - """ - Test that messages from other agents are ignored. - """ - # Arrange - msg = create_mock_message(sender_node="other_agent", body="", thread="") - mock_process_belief = mocker.patch.object(belief_setter_behaviour, "_process_belief_message") - - # Act - belief_setter_behaviour._process_message(msg) - - # Assert - mock_process_belief.assert_not_called() - - -def test_process_belief_message_valid_json(belief_setter_behaviour, mocker): - """ - Test processing a valid belief message with correct thread and JSON body. - """ - # Arrange - beliefs_payload = {"is_hot": ["kitchen"], "is_clean": ["kitchen", "bathroom"]} - msg = create_mock_message( - sender_node=COLLECTOR_AGENT_JID, body=json.dumps(beliefs_payload), thread="beliefs" - ) - mock_set_beliefs = mocker.patch.object(belief_setter_behaviour, "_set_beliefs") - - # Act - belief_setter_behaviour._process_belief_message(msg) - - # Assert - mock_set_beliefs.assert_called_once_with(beliefs_payload) - - -def test_process_belief_message_invalid_json(belief_setter_behaviour, mocker, caplog): - """ - Test that a message with invalid JSON is handled gracefully and an error is logged. - """ - # Arrange - msg = create_mock_message( - sender_node=COLLECTOR_AGENT_JID, body="this is not a json string", thread="beliefs" - ) - mock_set_beliefs = mocker.patch.object(belief_setter_behaviour, "_set_beliefs") - - # Act - belief_setter_behaviour._process_belief_message(msg) - - # Assert - mock_set_beliefs.assert_not_called() - - -def test_process_belief_message_wrong_thread(belief_setter_behaviour, mocker): - """ - Test that a message with an incorrect thread is ignored. - """ - # Arrange - msg = create_mock_message( - sender_node=COLLECTOR_AGENT_JID, body='{"some": "data"}', thread="not_beliefs" - ) - mock_set_beliefs = mocker.patch.object(belief_setter_behaviour, "_set_beliefs") - - # Act - belief_setter_behaviour._process_belief_message(msg) - - # Assert - mock_set_beliefs.assert_not_called() - - -def test_process_belief_message_empty_body(belief_setter_behaviour, mocker): - """ - Test that a message with an empty body is ignored. - """ - # Arrange - msg = create_mock_message(sender_node=COLLECTOR_AGENT_JID, body="", thread="beliefs") - mock_set_beliefs = mocker.patch.object(belief_setter_behaviour, "_set_beliefs") - - # Act - belief_setter_behaviour._process_belief_message(msg) - - # Assert - mock_set_beliefs.assert_not_called() - - -def test_set_beliefs_success(belief_setter_behaviour, mock_agent, caplog): - """ - Test that beliefs are correctly set on the agent's BDI. - """ - # Arrange - beliefs_to_set = { - "is_hot": ["kitchen"], - "door_opened": ["front_door", "back_door"], - } - - # Act - with caplog.at_level(logging.INFO): - belief_setter_behaviour._set_beliefs(beliefs_to_set) - - # Assert - expected_calls = [ - call("is_hot", "kitchen"), - call("door_opened", "front_door", "back_door"), - ] - mock_agent.bdi.set_belief.assert_has_calls(expected_calls, any_order=True) - assert mock_agent.bdi.set_belief.call_count == 2 - - -# def test_responded_unset(belief_setter_behaviour, mock_agent): -# # Arrange -# new_beliefs = {"user_said": ["message"]} -# -# # Act -# belief_setter_behaviour._set_beliefs(new_beliefs) -# -# # Assert -# mock_agent.bdi.set_belief.assert_has_calls([call("user_said", "message")]) -# mock_agent.bdi.remove_belief.assert_has_calls([call("responded")]) - -# def test_set_beliefs_bdi_not_initialized(belief_setter_behaviour, mock_agent, caplog): -# """ -# Test that a warning is logged if the agent's BDI is not initialized. -# """ -# # Arrange -# mock_agent.bdi = None # Simulate BDI not being ready -# beliefs_to_set = {"is_hot": ["kitchen"]} -# -# # Act -# with caplog.at_level(logging.WARNING): -# belief_setter_behaviour._set_beliefs(beliefs_to_set) -# -# # Assert -# assert "Cannot set beliefs, since agent's BDI is not yet initialized." in caplog.text diff --git a/test/unit/agents/bdi/test_bdi_core_agent.py b/test/unit/agents/bdi/test_bdi_core_agent.py new file mode 100644 index 0000000..84d11e4 --- /dev/null +++ b/test/unit/agents/bdi/test_bdi_core_agent.py @@ -0,0 +1,103 @@ +from unittest.mock import AsyncMock, MagicMock, mock_open, patch + +import pytest + +from control_backend.agents.bdi.bdi_core_agent.bdi_core_agent import BDICoreAgent +from control_backend.core.agent_system import InternalMessage +from control_backend.core.config import settings + + +@pytest.fixture +def mock_agentspeak_env(): + with patch("agentspeak.runtime.Environment") as mock_env: + yield mock_env + + +@pytest.fixture +def agent(): + agent = BDICoreAgent("bdi_agent", "dummy.asl") + agent.send = AsyncMock() + agent.bdi_agent = MagicMock() + return agent + + +@pytest.mark.asyncio +async def test_setup_loads_asl(mock_agentspeak_env, agent): + # Mock file opening + with patch("builtins.open", mock_open(read_data="+initial_goal.")): + await agent.setup() + + # Check if environment tried to build agent + mock_agentspeak_env.return_value.build_agent.assert_called() + + +@pytest.mark.asyncio +async def test_setup_no_asl(mock_agentspeak_env, agent): + with patch("builtins.open", side_effect=FileNotFoundError): + await agent.setup() + + mock_agentspeak_env.return_value.build_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_belief_collector_message(agent): + """Test that incoming beliefs are added to the BDI agent""" + # Simulate message from belief collector + import json + + beliefs = {"user_said": ["Hello"]} + msg = InternalMessage( + to="bdi_agent", + sender=settings.agent_settings.bdi_belief_collector_name, + body=json.dumps(beliefs), + thread="beliefs", + ) + + await agent.handle_message(msg) + + # Expect bdi_agent.call to be triggered to add belief + assert agent.bdi_agent.call.called + + +@pytest.mark.asyncio +async def test_handle_llm_response(agent): + """Test that LLM responses are forwarded to the Robot Speech Agent""" + msg = InternalMessage( + to="bdi_agent", sender=settings.agent_settings.llm_name, body="This is the LLM reply" + ) + + await agent.handle_message(msg) + + # Verify forward + assert agent.send.called + sent_msg = agent.send.call_args[0][0] + assert sent_msg.to == settings.agent_settings.robot_speech_name + assert "This is the LLM reply" in sent_msg.body + + +@pytest.mark.asyncio +async def test_custom_actions(agent): + agent._send_to_llm = MagicMock(side_effect=agent.send) # Mock specific method + + # Initialize actions manually since we didn't call setup with real file + agent._add_custom_actions() + + # Find the action + action_fn = None + for (functor, _), fn in agent.actions.actions.items(): + if functor == ".reply": + action_fn = fn + break + + assert action_fn is not None + + # Invoke action + mock_term = MagicMock() + mock_term.args = ["Hello"] + mock_intention = MagicMock() + + # Run generator + gen = action_fn(agent, mock_term, mock_intention) + next(gen) # Execute + + agent._send_to_llm.assert_called_with("Hello") diff --git a/test/unit/agents/bdi/belief_collector_agent/test_continuous_collect.py b/test/unit/agents/bdi/test_belief_collector.py similarity index 100% rename from test/unit/agents/bdi/belief_collector_agent/test_continuous_collect.py rename to test/unit/agents/bdi/test_belief_collector.py diff --git a/test/unit/agents/bdi/text_belief_agent/test_belief_from_text.py b/test/unit/agents/bdi/test_text_extractor.py similarity index 68% rename from test/unit/agents/bdi/text_belief_agent/test_belief_from_text.py rename to test/unit/agents/bdi/test_text_extractor.py index 4fbd51a..2e0d4b1 100644 --- a/test/unit/agents/bdi/text_belief_agent/test_belief_from_text.py +++ b/test/unit/agents/bdi/test_text_extractor.py @@ -7,16 +7,6 @@ from control_backend.agents.bdi.text_belief_extractor_agent.text_belief_extracto TextBeliefExtractorAgent, ) from control_backend.core.agent_system import InternalMessage -from control_backend.core.config import settings - - -@pytest.fixture(autouse=True) -def patch_settings(monkeypatch): - monkeypatch.setattr(settings.agent_settings, "transcription_name", "transcriber", raising=False) - monkeypatch.setattr( - settings.agent_settings, "bdi_belief_collector_name", "collector", raising=False - ) - monkeypatch.setattr(settings.agent_settings, "host", "fake.host", raising=False) @pytest.fixture @@ -40,29 +30,29 @@ async def test_handle_message_ignores_other_agents(agent): @pytest.mark.asyncio -async def test_handle_message_from_transcriber(agent): +async def test_handle_message_from_transcriber(agent, mock_settings): transcription = "hello world" - msg = make_msg(settings.agent_settings.transcription_name, transcription, None) + msg = make_msg(mock_settings.agent_settings.transcription_name, transcription, None) await agent.handle_message(msg) agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. sent: InternalMessage = agent.send.call_args.args[0] # noqa - assert sent.to == settings.agent_settings.bdi_belief_collector_name + assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name assert sent.thread == "beliefs" parsed = json.loads(sent.body) assert parsed == {"beliefs": {"user_said": [transcription]}, "type": "belief_extraction_text"} @pytest.mark.asyncio -async def test_process_transcription_demo(agent): +async def test_process_transcription_demo(agent, mock_settings): transcription = "this is a test" await agent._process_transcription_demo(transcription) agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. sent: InternalMessage = agent.send.call_args.args[0] # noqa - assert sent.to == settings.agent_settings.bdi_belief_collector_name + assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name assert sent.thread == "beliefs" parsed = json.loads(sent.body) assert parsed["beliefs"]["user_said"] == [transcription] diff --git a/test/integration/agents/communication/test_ri_communication_agent.py b/test/unit/agents/communication/test_ri_communication_agent.py similarity index 100% rename from test/integration/agents/communication/test_ri_communication_agent.py rename to test/unit/agents/communication/test_ri_communication_agent.py diff --git a/test/unit/agents/llm/test_llm_agent.py b/test/unit/agents/llm/test_llm_agent.py new file mode 100644 index 0000000..4a8b7df --- /dev/null +++ b/test/unit/agents/llm/test_llm_agent.py @@ -0,0 +1,124 @@ +"""Mocks `httpx` and tests chunking logic.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from control_backend.agents.llm.llm_agent import LLMAgent, LLMInstructions +from control_backend.core.agent_system import InternalMessage + + +@pytest.fixture +def mock_httpx_client(): + with patch("httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_cls.return_value.__aenter__.return_value = mock_client + yield mock_client + + +@pytest.mark.asyncio +async def test_llm_processing_success(mock_httpx_client, mock_settings): + # Setup the mock response for the stream + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + + # Simulate stream lines + lines = [ + b'data: {"choices": [{"delta": {"content": "Hello"}}]}', + b'data: {"choices": [{"delta": {"content": " world"}}]}', + b'data: {"choices": [{"delta": {"content": "."}}]}', + b"data: [DONE]", + ] + + async def aiter_lines_gen(): + for line in lines: + yield line.decode() + + mock_response.aiter_lines.side_effect = aiter_lines_gen + + mock_stream_context = MagicMock() + mock_stream_context.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_context.__aexit__ = AsyncMock(return_value=None) + + # Configure the client + mock_httpx_client.stream = MagicMock(return_value=mock_stream_context) + + # Setup Agent + agent = LLMAgent("llm_agent") + agent.send = AsyncMock() # Mock the send method to verify replies + + # Simulate receiving a message from BDI + msg = InternalMessage( + to="llm_agent", sender=mock_settings.agent_settings.bdi_core_name, body="Hi" + ) + + await agent.handle_message(msg) + + # Verification + # "Hello world." constitutes one sentence/chunk based on punctuation split + # The agent should call send once with the full sentence + assert agent.send.called + args = agent.send.call_args[0][0] + assert args.to == mock_settings.agent_settings.bdi_core_name + assert "Hello world." in args.body + + +@pytest.mark.asyncio +async def test_llm_processing_errors(mock_httpx_client, mock_settings): + agent = LLMAgent("llm_agent") + agent.send = AsyncMock() + msg = InternalMessage(to="llm", sender=mock_settings.agent_settings.bdi_core_name, body="Hi") + + # HTTP Error + mock_httpx_client.stream = MagicMock(side_effect=httpx.HTTPError("Fail")) + await agent.handle_message(msg) + assert "LLM service unavailable." in agent.send.call_args[0][0].body + + # General Exception + agent.send.reset_mock() + mock_httpx_client.stream = MagicMock(side_effect=Exception("Boom")) + await agent.handle_message(msg) + assert "Error processing the request." in agent.send.call_args[0][0].body + + +@pytest.mark.asyncio +async def test_llm_json_error(mock_httpx_client, mock_settings): + # Test malformed JSON in stream + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + + async def aiter_lines_gen(): + yield "data: {bad_json" + yield "data: [DONE]" + + mock_response.aiter_lines.side_effect = aiter_lines_gen + + mock_stream_context = MagicMock() + mock_stream_context.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_context.__aexit__ = AsyncMock(return_value=None) + mock_httpx_client.stream = MagicMock(return_value=mock_stream_context) + + agent = LLMAgent("llm_agent") + agent.send = AsyncMock() + + with patch.object(agent.logger, "error") as log: + msg = InternalMessage( + to="llm", sender=mock_settings.agent_settings.bdi_core_name, body="Hi" + ) + await agent.handle_message(msg) + log.assert_called() # Should log JSONDecodeError + + +def test_llm_instructions(): + # Full custom + instr = LLMInstructions(norms="N", goals="G") + text = instr.build_developer_instruction() + assert "Norms to follow:\nN" in text + assert "Goals to reach:\nG" in text + + # Defaults + instr_def = LLMInstructions() + text_def = instr_def.build_developer_instruction() + assert "Norms to follow" in text_def + assert "Goals to reach" in text_def diff --git a/test/unit/agents/perception/transcription_agent/test_transcription_agent.py b/test/unit/agents/perception/transcription_agent/test_transcription_agent.py new file mode 100644 index 0000000..4a5d928 --- /dev/null +++ b/test/unit/agents/perception/transcription_agent/test_transcription_agent.py @@ -0,0 +1,122 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import numpy as np +import pytest + +from control_backend.agents.perception.transcription_agent.speech_recognizer import ( + MLXWhisperSpeechRecognizer, + OpenAIWhisperSpeechRecognizer, + SpeechRecognizer, +) +from control_backend.agents.perception.transcription_agent.transcription_agent import ( + TranscriptionAgent, +) + + +@pytest.mark.asyncio +async def test_transcription_agent_flow(mock_zmq_context): + mock_sub = MagicMock() + mock_sub.recv = AsyncMock() + + # Setup context to return this specific mock socket + mock_zmq_context.instance.return_value.socket.return_value = mock_sub + + # Data: [Audio Bytes, Cancel Loop] + fake_audio = np.zeros(16000, dtype=np.float32).tobytes() + mock_sub.recv.side_effect = [fake_audio, asyncio.CancelledError()] + + # Mock Recognizer + with patch.object(SpeechRecognizer, "best_type") as mock_best: + mock_recognizer = MagicMock() + mock_recognizer.recognize_speech.return_value = "Hello" + mock_best.return_value = mock_recognizer + + agent = TranscriptionAgent("tcp://in") + agent.send = AsyncMock() + + agent._running = True + agent.add_background_task = AsyncMock() + + await agent.setup() + + try: + await agent._transcribing_loop() + except asyncio.CancelledError: + pass + + # Check transcription happened + assert mock_recognizer.recognize_speech.called + # Check sending + assert agent.send.called + assert agent.send.call_args[0][0].body == "Hello" + + await agent.stop() + + +@pytest.mark.asyncio +async def test_transcription_empty(mock_zmq_context): + mock_sub = MagicMock() + mock_sub.recv = AsyncMock() + mock_zmq_context.instance.return_value.socket.return_value = mock_sub + + # Return valid audio, but recognizer returns empty string + fake_audio = np.zeros(10, dtype=np.float32).tobytes() + mock_sub.recv.side_effect = [fake_audio, asyncio.CancelledError()] + + with patch.object(SpeechRecognizer, "best_type") as mock_best: + mock_recognizer = MagicMock() + mock_recognizer.recognize_speech.return_value = "" + mock_best.return_value = mock_recognizer + + agent = TranscriptionAgent("tcp://in") + agent.send = AsyncMock() + await agent.setup() + + try: + await agent._transcribing_loop() + except asyncio.CancelledError: + pass + + # Should NOT send message + agent.send.assert_not_called() + + +def test_speech_recognizer_factory(): + # Test Factory Logic + with patch("torch.mps.is_available", return_value=True): + assert isinstance(SpeechRecognizer.best_type(), MLXWhisperSpeechRecognizer) + + with patch("torch.mps.is_available", return_value=False): + assert isinstance(SpeechRecognizer.best_type(), OpenAIWhisperSpeechRecognizer) + + +def test_openai_recognizer(): + with patch("whisper.load_model") as load_mock: + with patch("whisper.transcribe") as trans_mock: + rec = OpenAIWhisperSpeechRecognizer() + rec.load_model() + load_mock.assert_called() + + trans_mock.return_value = {"text": "Hi"} + res = rec.recognize_speech(np.zeros(10)) + assert res == "Hi" + + +def test_mlx_recognizer(): + # Fix: On Linux, 'mlx_whisper' isn't imported by the module, so it's missing from dir(). + # We must use create=True to inject it into the module namespace during the test. + module_path = "control_backend.agents.perception.transcription_agent.speech_recognizer" + + with patch("sys.platform", "darwin"): + with patch(f"{module_path}.mlx_whisper", create=True) as mlx_mock: + with patch(f"{module_path}.ModelHolder", create=True) as holder_mock: + # We also need to mock mlx.core if it's used for types/constants + with patch(f"{module_path}.mx", create=True): + rec = MLXWhisperSpeechRecognizer() + rec.load_model() + holder_mock.get_model.assert_called() + + mlx_mock.transcribe.return_value = {"text": "Hi"} + res = rec.recognize_speech(np.zeros(10)) + assert res == "Hi" diff --git a/test/integration/api/endpoints/test_robot_endpoint.py b/test/unit/api/v1/endpoints/test_robot_endpoint.py similarity index 100% rename from test/integration/api/endpoints/test_robot_endpoint.py rename to test/unit/api/v1/endpoints/test_robot_endpoint.py diff --git a/test/unit/conftest.py b/test/unit/conftest.py index fdd8f6c..6ab989e 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -1,71 +1,43 @@ -import sys -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import pytest + +from control_backend.core.agent_system import _agent_directory -def pytest_configure(config): +@pytest.fixture(autouse=True) +def reset_agent_directory(): """ - This hook runs at the start of the pytest session, before any tests are - collected. It mocks heavy or unavailable modules to prevent ImportErrors. + Automatically clears the global agent directory before and after each test + to prevent state leakage between tests. """ - # --- Mock spade and spade-bdi --- - mock_agentspeak = MagicMock() - mock_httpx = MagicMock() - mock_pydantic = MagicMock() - mock_spade = MagicMock() - mock_spade.agent = MagicMock() - mock_spade.behaviour = MagicMock() - mock_spade.message = MagicMock() - mock_spade_bdi = MagicMock() - mock_spade_bdi.bdi = MagicMock() + _agent_directory.clear() + yield + _agent_directory.clear() - mock_spade.agent.Message = MagicMock() - mock_spade.behaviour.CyclicBehaviour = type("CyclicBehaviour", (object,), {}) - mock_spade_bdi.bdi.BDIAgent = type("BDIAgent", (object,), {}) - # Ensure submodule imports like `agentspeak.runtime` succeed - mock_agentspeak.runtime = MagicMock() - mock_agentspeak.stdlib = MagicMock() - sys.modules["agentspeak"] = mock_agentspeak - sys.modules["agentspeak.runtime"] = mock_agentspeak.runtime - sys.modules["agentspeak.stdlib"] = mock_agentspeak.stdlib - sys.modules["httpx"] = mock_httpx - sys.modules["pydantic"] = mock_pydantic - sys.modules["spade"] = mock_spade - sys.modules["spade.agent"] = mock_spade.agent - sys.modules["spade.behaviour"] = mock_spade.behaviour - sys.modules["spade.message"] = mock_spade.message - sys.modules["spade_bdi"] = mock_spade_bdi - sys.modules["spade_bdi.bdi"] = mock_spade_bdi.bdi +@pytest.fixture +def mock_settings(): + with patch("control_backend.core.config.settings") as mock: + # Set default values that match the pydantic model defaults + # to avoid AttributeErrors during tests + mock.zmq_settings.internal_pub_address = "tcp://localhost:5560" + mock.zmq_settings.internal_sub_address = "tcp://localhost:5561" + mock.zmq_settings.ri_command_address = "tcp://localhost:0000" + mock.agent_settings.bdi_core_name = "bdi_core_agent" + mock.agent_settings.bdi_belief_collector_name = "belief_collector_agent" + mock.agent_settings.llm_name = "llm_agent" + mock.agent_settings.robot_speech_name = "robot_speech_agent" + mock.agent_settings.transcription_name = "transcription_agent" + mock.agent_settings.text_belief_extractor_name = "text_belief_extractor_agent" + mock.agent_settings.vad_name = "vad_agent" + mock.behaviour_settings.sleep_s = 0.01 # Speed up tests + mock.behaviour_settings.comm_setup_max_retries = 1 + yield mock - # --- Mock the config module to prevent Pydantic ImportError --- - mock_config_module = MagicMock() - # The code under test does `from ... import settings`, so our mock module - # must have a `settings` attribute. We'll make it a MagicMock so we can - # configure it later in our tests using mocker.patch. - mock_config_module.settings = MagicMock() - - sys.modules["control_backend.core.config"] = mock_config_module - - # --- Mock torch and zmq for VAD --- - mock_torch = MagicMock() - mock_zmq = MagicMock() - mock_zmq.asyncio = mock_zmq - - # In individual tests, these can be imported and the return values changed - sys.modules["torch"] = mock_torch - sys.modules["zmq"] = mock_zmq - sys.modules["zmq.asyncio"] = mock_zmq.asyncio - - # --- Mock whisper --- - mock_whisper = MagicMock() - mock_mlx = MagicMock() - mock_mlx.core = MagicMock() - mock_mlx_whisper = MagicMock() - mock_mlx_whisper.transcribe = MagicMock() - - sys.modules["whisper"] = mock_whisper - sys.modules["mlx"] = mock_mlx - sys.modules["mlx.core"] = mock_mlx - sys.modules["mlx_whisper"] = mock_mlx_whisper - sys.modules["mlx_whisper.transcribe"] = mock_mlx_whisper.transcribe +@pytest.fixture +def mock_zmq_context(): + with patch("zmq.asyncio.Context") as mock: + mock.instance.return_value = MagicMock() + yield mock diff --git a/test/unit/core/test_agent_system.py b/test/unit/core/test_agent_system.py new file mode 100644 index 0000000..ee26c48 --- /dev/null +++ b/test/unit/core/test_agent_system.py @@ -0,0 +1,68 @@ +"""Test the base class logic, message passing and background task handling.""" + +import asyncio +import logging + +import pytest + +from control_backend.core.agent_system import AgentDirectory, BaseAgent, InternalMessage + + +class ConcreteTestAgent(BaseAgent): + logger = logging.getLogger("test") + + def __init__(self, name: str): + super().__init__(name) + self.received = [] + + async def setup(self): + pass + + async def handle_message(self, msg: InternalMessage): + self.received.append(msg) + if msg.body == "stop": + await self.stop() + + +@pytest.mark.asyncio +async def test_agent_lifecycle(): + agent = ConcreteTestAgent("lifecycle_agent") + await agent.start() + assert agent._running is True + + # Test background task + async def dummy_task(): + await asyncio.sleep(0.01) + + await agent.add_background_task(dummy_task()) + assert len(agent._tasks) > 0 + + # Wait for task to finish + await asyncio.sleep(0.02) + assert len(agent._tasks) == 1 # _process_inbox is still running + + await agent.stop() + assert agent._running is False + + await asyncio.sleep(0.01) + + # Tasks should be cancelled + assert len(agent._tasks) == 0 + + +@pytest.mark.asyncio +async def test_send_unknown_agent(caplog): + agent = ConcreteTestAgent("sender") + msg = InternalMessage(to="unknown_sender", sender="sender", body="boo") + + with caplog.at_level(logging.WARNING): + await agent.send(msg) + + assert "Attempted to send message to unknown agent: unknown_sender" in caplog.text + + +@pytest.mark.asyncio +async def test_get_agent(): + agent = ConcreteTestAgent("registrant") + assert AgentDirectory.get("registrant") == agent + assert AgentDirectory.get("non_existent") is None diff --git a/test/unit/core/test_config.py b/test/unit/core/test_config.py new file mode 100644 index 0000000..1e23b03 --- /dev/null +++ b/test/unit/core/test_config.py @@ -0,0 +1,14 @@ +"""Test if settings load correctly and environment variables override defaults.""" + +from control_backend.core.config import Settings + + +def test_default_settings(): + settings = Settings() + assert settings.app_title == "PepperPlus" + + +def test_env_override(monkeypatch): + monkeypatch.setenv("APP_TITLE", "TestPepper") + settings = Settings() + assert settings.app_title == "TestPepper" diff --git a/test/unit/core/test_logging.py b/test/unit/core/test_logging.py new file mode 100644 index 0000000..9f0cbed --- /dev/null +++ b/test/unit/core/test_logging.py @@ -0,0 +1,88 @@ +import logging +from unittest.mock import mock_open, patch + +import pytest + +from control_backend.logging.setup_logging import add_logging_level, setup_logging + + +def test_add_logging_level(): + # Add a unique level to avoid conflicts with other tests/libraries + level_name = "TESTLEVEL" + level_num = 35 + + add_logging_level(level_name, level_num) + + assert logging.getLevelName(level_num) == level_name + assert hasattr(logging, level_name) + assert hasattr(logging.getLoggerClass(), level_name.lower()) + + # Test functionality + logger = logging.getLogger("test_custom_level") + with patch.object(logger, "_log") as mock_log: + getattr(logger, level_name.lower())("message") + mock_log.assert_called_with(level_num, "message", ()) + + # Test duplicates + with pytest.raises(AttributeError): + add_logging_level(level_name, level_num) + + with pytest.raises(AttributeError): + add_logging_level("INFO", 20) # Existing level + + +def test_setup_logging_no_file(caplog): + with patch("os.path.exists", return_value=False): + setup_logging("dummy.yaml") + assert "Logging config file not found" in caplog.text + + +def test_setup_logging_yaml_error(caplog): + with patch("os.path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data="invalid: [yaml")): + with patch("logging.config.dictConfig") as mock_dict_config: + setup_logging("config.yaml") + + # Verify we logged the warning + assert "Could not load logging configuration" in caplog.text + # Verify dictConfig was called with empty dict (which would crash real dictConfig) + mock_dict_config.assert_called_with({}) + assert "Could not load logging configuration" in caplog.text + + +def test_setup_logging_success(): + config_data = """ + version: 1 + handlers: + console: + class: logging.StreamHandler + root: + handlers: [console] + level: INFO + custom_levels: + MYLEVEL: 15 + """ + with patch("os.path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=config_data)): + with patch("logging.config.dictConfig") as mock_dict_config: + setup_logging("config.yaml") + mock_dict_config.assert_called() + assert hasattr(logging, "MYLEVEL") + + +def test_setup_logging_zmq_handler(mock_zmq_context): + config_data = """ + version: 1 + handlers: + ui: + class: logging.NullHandler + # In real config this would be a zmq handler, but for unit test logic + # we just want to see if the socket injection happens + """ + with patch("os.path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=config_data)): + with patch("logging.config.dictConfig") as mock_dict_config: + setup_logging("config.yaml") + + args = mock_dict_config.call_args[0][0] + assert "interface_or_socket" in args["handlers"]["ui"] diff --git a/test/integration/schemas/test_ri_message.py b/test/unit/schemas/test_ri_message.py similarity index 100% rename from test/integration/schemas/test_ri_message.py rename to test/unit/schemas/test_ri_message.py diff --git a/test/integration/schemas/test_ui_program_message.py b/test/unit/schemas/test_ui_program_message.py similarity index 100% rename from test/integration/schemas/test_ui_program_message.py rename to test/unit/schemas/test_ui_program_message.py diff --git a/uv.lock b/uv.lock index c2a4f21..2196aa2 100644 --- a/uv.lock +++ b/uv.lock @@ -114,11 +114,11 @@ wheels = [ [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] @@ -996,10 +996,6 @@ dependencies = [ { name = "pyaudio" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "pytest-mock" }, { name = "python-json-logger" }, { name = "pyyaml" }, { name = "pyzmq" }, @@ -1013,18 +1009,13 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pre-commit" }, - { name = "ruff" }, - { name = "ruff-format" }, -] -integration-test = [ - { name = "soundfile" }, -] -test = [ - { name = "numpy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "ruff" }, + { name = "ruff-format" }, + { name = "soundfile" }, ] [package.metadata] @@ -1038,10 +1029,6 @@ requires-dist = [ { name = "pyaudio", specifier = ">=0.2.14" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, - { name = "pytest", specifier = ">=8.4.2" }, - { name = "pytest-asyncio", specifier = ">=1.2.0" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, - { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "python-json-logger", specifier = ">=4.0.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyzmq", specifier = ">=27.1.0" }, @@ -1055,16 +1042,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4.3.0" }, - { name = "ruff", specifier = ">=0.14.2" }, - { name = "ruff-format", specifier = ">=0.3.0" }, -] -integration-test = [{ name = "soundfile", specifier = ">=0.13.1" }] -test = [ - { name = "numpy", specifier = ">=2.3.3" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-mock", specifier = ">=3.15.1" }, + { name = "ruff", specifier = ">=0.14.2" }, + { name = "ruff-format", specifier = ">=0.3.0" }, + { name = "soundfile", specifier = ">=0.13.1" }, ] [[package]] @@ -1087,7 +1071,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.3.0" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1096,9 +1080,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, ] [[package]] @@ -1548,58 +1532,64 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.2" +version = "0.14.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, - { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, - { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, - { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, - { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, - { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, - { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, - { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, + { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, ] [[package]] name = "ruff-format" -version = "0.3.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/3c/71dfce0e8269271969381b1a629772aeeb62c693f8aca8560bf145e413ca/ruff_format-0.3.0.tar.gz", hash = "sha256:f579b32b9dd041b0fe7b04da9ba932ff5d108f7ce4c763bd58e659a03f1d408a", size = 15541, upload-time = "2025-10-10T03:13:11.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/ae/5767b436b41af8add7432286fce65a489a4db88ed090fb66275fa0076cd3/ruff_format-0.4.1.tar.gz", hash = "sha256:2aff271154b088ee131cef63a92afbc4cdc3905acf03d279c4a8aa3f6b3fb564", size = 15622, upload-time = "2025-10-28T18:31:39.817Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/b9/5866b53f870231f61716753b471cca1c79042678b96d25bff75ca1ee361a/ruff_format-0.3.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46e543b0c6c858d963ca337ded9e37887ba6fc903caf13bd7200274faef9178c", size = 2127810, upload-time = "2025-10-10T03:12:42.416Z" }, - { url = "https://files.pythonhosted.org/packages/42/0a/311803a69bb9302749eb22b4a193cc87dfe172a5ee6940d3e4c9362418f5/ruff_format-0.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:d549c4cd5e6ae1fac9c4c083b5c3d51bca5b1fdb622384bd5dd2c1d01f99dc66", size = 2059792, upload-time = "2025-10-10T03:12:40.849Z" }, - { url = "https://files.pythonhosted.org/packages/17/bb/7e09e91464291dc1f4b947d858d1206b3df618fdb96cda17fad3bc245977/ruff_format-0.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cb10f784ff0dc8f57183d7edbf33ce32d8efd8582794e9415c8a53a0e6d0e0b", size = 2247834, upload-time = "2025-10-10T03:12:06.404Z" }, - { url = "https://files.pythonhosted.org/packages/6d/20/8d1d5c63acacee481e7a92e8d5a9cfa1fa6266082bf844f66c981033b43b/ruff_format-0.3.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adf38aae1b1468c55f4f8732d077bb30dd705599875cf6783bbb1808373d9fa4", size = 2187813, upload-time = "2025-10-10T03:12:13.535Z" }, - { url = "https://files.pythonhosted.org/packages/bd/87/c23b0ef5efa4624882601fbcacc8e64f4f1687387acb1873babb82413e27/ruff_format-0.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:642e8edadbc348718ef4aaf750ffa993376338669d5bf7c085c66d1a181ea26f", size = 3076735, upload-time = "2025-10-10T03:12:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/df/60/2dd758eac6f835505de4bdcf7be5c993a930e6f6c475bec21e92df1359e5/ruff_format-0.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e894da47f72e538731793953b213c80e17aeea5635067e2054c9a8ffe71331b", size = 2393207, upload-time = "2025-10-10T03:12:28.3Z" }, - { url = "https://files.pythonhosted.org/packages/29/8c/f55bcc419596929da754ffa59f415e498a17be1a32b2a59c472440526625/ruff_format-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2c73eabe1f9a08ca7430f317c358bb31c3e0017b262488bac636a50cc7d7948d", size = 2429534, upload-time = "2025-10-10T03:12:43.675Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ae/24e1bf20a13d67fd4b4629efa8c015a20de9fa09ec3767b27a5e0beec4c7/ruff_format-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:470ca14276c98eb06404c0966d3b306c63c1560fd926416fd5c6c00f24f3410c", size = 2445547, upload-time = "2025-10-10T03:12:50.626Z" }, - { url = "https://files.pythonhosted.org/packages/c8/aa/5c343854a1d6c74a1db7ecd345f7fa6712f7b73adabd9c6ceb5db4356a69/ruff_format-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ebdf4a35223860e7a697ef3a2d5dc0cf1c94656b09ba9139b400c1602c18db3a", size = 2452623, upload-time = "2025-10-10T03:12:57.66Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0f/8ffaa38f228176478ca6f1e9faf23749220f3fd97ad804559ac85e3cfc98/ruff_format-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3bf308531ad99a745438701df88d306a416d002a36143b23c5b5dad85965a42", size = 2473830, upload-time = "2025-10-10T03:13:05.376Z" }, - { url = "https://files.pythonhosted.org/packages/13/2f/3f53cfb6f14d2f2bfcf29fef41712ee04caa84155334e4602db1e08523d8/ruff_format-0.3.0-cp314-abi3-win32.whl", hash = "sha256:cc9e2bf654290999a2d0bdac8dd289302dcbc8cced2db5e1600f1d1850b4066e", size = 1785021, upload-time = "2025-10-10T03:13:13.785Z" }, - { url = "https://files.pythonhosted.org/packages/64/49/81c0ebc86540f856e0f1ffa6d47a95111328306650f63d6a453d34f05295/ruff_format-0.3.0-cp314-abi3-win_amd64.whl", hash = "sha256:52d47afcf18cd070e9ea8eb7701b6942a28323089fdd4a7a8934c68e57228475", size = 1892439, upload-time = "2025-10-10T03:13:12.546Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/bfaf109bb50cc1c108d494288072419ba3acf0e9bfcf3be587b707454c50/ruff_format-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:623156d3a1e2ef8ece2b7195aa64f122c036605ce495e06e99c53a52927b7871", size = 2249416, upload-time = "2025-10-10T03:12:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/8c/01/113a0e8f15dc1309b6331695a084bc36207b26fad065c26abfadbf24f5a7/ruff_format-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073d4be5fb2fbb6668e14fb9a3aae1b03bbb2ef6d63622979e5657d22a69fb36", size = 2190621, upload-time = "2025-10-10T03:12:14.806Z" }, - { url = "https://files.pythonhosted.org/packages/66/8d/979b6ccde9fe4018b01a9a4215cc4c3455519465943c9862876311e239da/ruff_format-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45e34fe85e7bc833f85e873f6cb9e3606510e678760c7128c737b009e3b9fdfd", size = 3077988, upload-time = "2025-10-10T03:12:22.204Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/791ce063a6bf17c783fe036f302bfcec8a9e1f99bf591e8b0cc73a25b719/ruff_format-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:135f1306e51198790fcf402c6574539e51dc1bcfa6d8c67e8b51c701d9ebab11", size = 2395129, upload-time = "2025-10-10T03:12:29.808Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/08df01b8925ea4fdf7959199ccffc599314a179695fa8bc886146971b30b/ruff_format-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451d3502ccd85ec055fdc1ce52f60f6c8d469bda3b8c7a3e9ac5fa99a64fde9c", size = 2302808, upload-time = "2025-10-10T03:12:38.299Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/24d3616081e283b38cf228a6765b913fd1320e780febd4ea3ec98a0db5ff/ruff_format-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76e0c088e18bd23b124d225926b8d64db6419a7f86b3a123346e2bacae679940", size = 2364885, upload-time = "2025-10-10T03:12:35.341Z" }, - { url = "https://files.pythonhosted.org/packages/05/2f/3efec36107cd974ed48ab63b61b15e49139575ff305daf0c52c24ea14cdb/ruff_format-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:81651ba409a6de07f5c6b25ac609401649a3cccdd19c7cb76e735481e6ed859a", size = 2431420, upload-time = "2025-10-10T03:12:45.127Z" }, - { url = "https://files.pythonhosted.org/packages/f7/bb/9ec44a9203f668974a896efc9cf26c9e332226b578f7ae6ca3449642e7cb/ruff_format-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:da2d9cc4d0c4cfd5b8180a19f0b8eda86cc2cffc0e5d01dd2b6133eb85e7e76f", size = 2447058, upload-time = "2025-10-10T03:12:51.926Z" }, - { url = "https://files.pythonhosted.org/packages/a0/57/be709bc005ec1008773a9361b0d1dac23fc0425ea2510b3b575cb3d44865/ruff_format-0.3.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c0f1c971a9eb50b7145158fd96ac29d5d5aaf4373c9d4c438113a1a09a97be03", size = 2453965, upload-time = "2025-10-10T03:12:59.07Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a4/3a09b363d5bf7c4e2b97f770b308973759dce2acdf296b4023c3239ae7a7/ruff_format-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c905725e0dad3016a0c7cd16eea64edec7bc42cd60036378a4e206a56ee565fd", size = 2475816, upload-time = "2025-10-10T03:13:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/2e/00/2d7778a97bcae6a3e1ddbc740936b0fcc7e7abe2e0ee054b18b4100bed5c/ruff_format-0.4.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ced72ef509b427483b27c7d85411fe9e11cba38cfd20f899579a22f7582fa598", size = 2148646, upload-time = "2025-10-28T18:31:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/be/da/1c136748eeb09609c06859fdfec93e3f35f928ca2be7ca34973df172bb39/ruff_format-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:af3df907875a5ac8e156a9b63163fa4385ba629d743970b20a86a1f6eaaf8f20", size = 2087325, upload-time = "2025-10-28T18:31:22.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/bba49938eeeb6b57a26cd86923c82d7fa52f0ee80cb79aad0e3cc75ca815/ruff_format-0.4.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:226155424d5454998697593d70518a77a6fb85ffb78f334e9ec3e651977289da", size = 2275529, upload-time = "2025-10-28T18:31:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/43/ea43a81b45a62c0b4475d85062c829de24b8465255f1af01ba8819db5dac/ruff_format-0.4.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b363a61c116e5d74f4909b95708049a5f1a577a8fb0e75ac4d1b2bd02eac7440", size = 2221105, upload-time = "2025-10-28T18:31:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/7127239e4aeec9afe61bb37c41296c8cd858b5ad4b7f46eeb8ba418b9f1f/ruff_format-0.4.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a990c9439bd658d481641ba87051471f9f205a33d32dbfa2c4aa1b3448eea3bb", size = 3136332, upload-time = "2025-10-28T18:31:11.572Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b9/b59a438d9197e73347f4b6fad498e36f41fd3c56984614ed016f7b53fb2e/ruff_format-0.4.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:014714c227f2d12eb988bc1913aa58f8113aa25b0056220eff77ed9e2a5c31bd", size = 2431819, upload-time = "2025-10-28T18:31:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e9/1a365b24d5c5bf1838a267eda49a399dd491104740210d11db817ca8dbf7/ruff_format-0.4.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6adf7223143f42c89cb05cb0dd3ff2fc578c57ea84de6e0809b9e3324ff926c", size = 2331924, upload-time = "2025-10-28T18:31:19.791Z" }, + { url = "https://files.pythonhosted.org/packages/2e/79/79b8f418bce106a45a9c56c10722266782f3d987351aa6e88f385c27f99f/ruff_format-0.4.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56e17ee28d47e4572f495221e96cc6fb87102aaed8f48110816e0c02768f2a6e", size = 2402844, upload-time = "2025-10-28T18:31:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/67/e7/fa9e7d5b48a5bf0c285428f9861e389dbd0b6dae0040a4a16db02416090b/ruff_format-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67f4402da91604d73dac8ce35a56419cf3ecd874ff8d26cd82122220a5fdca25", size = 2455140, upload-time = "2025-10-28T18:31:28.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0c/384d7983e6b33076e8d96aa96f669120932816d8e837f70495299d71459a/ruff_format-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c3300140c01622a3f43e968dd226a42d19b1e6d5d9ce45c8c595e8f5918aa8cf", size = 2488239, upload-time = "2025-10-28T18:31:31.463Z" }, + { url = "https://files.pythonhosted.org/packages/ac/6d/df79fca14652a70b287458626bbadb26978086395726c0010f0438e114e6/ruff_format-0.4.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8bc80217523edec20d76f03fe65872278a347f09ccfe4993641d0687782202cc", size = 2493273, upload-time = "2025-10-28T18:31:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/f46613603c24a33377e129b75499585f88e89cdeae0bb3e92be6c717f02e/ruff_format-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d127ceb09f8938ceb91a9750dce59ac03e81e6c148a18d4baa2ecccb3df68bef", size = 2505607, upload-time = "2025-10-28T18:31:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c0/6ebccef59083405b77a5c95779d1acece038f0b5ed223b19cc1704d2755d/ruff_format-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:75c7f4cb08466dbd1d042c0c6013d25d4b61780f08aa05a8384bb553a73d88d7", size = 1818617, upload-time = "2025-10-28T18:31:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ee/17aeeffa8dd4241360a8c6f9937e99cbbfb9d52dc033d382c9d4a87fec0e/ruff_format-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:0f5dccef951c5e161087c930ccbd8969712acc6081dce520fe31555025d5602f", size = 1923603, upload-time = "2025-10-28T18:31:40.526Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0f/47755cda55e7d55211e43d5de61d80bd75d1c0f63d3df996d53d5cfbe1c5/ruff_format-0.4.1-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60c5aca1dc8b0fa5133bd7983035999cce03daf97da24668945d69114c630dc8", size = 2153211, upload-time = "2025-10-28T18:31:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/daeff0742bc47c2fc56d250049bdf6b81074d23dcab3b9486c9c33857cb1/ruff_format-0.4.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f1eb8a30f2c03d27764a2985b56b2053d2f2e74a65cc98550edbc71aa4bfbb3c", size = 2090816, upload-time = "2025-10-28T18:31:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/f0/64525240a46f1bdc9e7cf7c53342ab271c2ab22cd10f4c016153b6582ecb/ruff_format-0.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae67a0d1d9b6826deefbbf3c326bcf9a4d4e6cd46536f8f7d33d2cd06bc79b97", size = 2280121, upload-time = "2025-10-28T18:31:06.578Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/192aaa9e3ea2003f1da44cf3d766b93325dfae41ffbdc0133506456af06c/ruff_format-0.4.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7edcbaed5c66e46bf1c57efa93151624cfe6eb7f2af0864fe6194da4dad01524", size = 2226372, upload-time = "2025-10-28T18:31:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/8759d7525b394b78dfa8484eba64db8373293f655e42a2ea48bf18a4028c/ruff_format-0.4.1-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0151c72b3f4a8eb2dbbcee360e68581676e7e490cd3cc4ba098fb96a831659db", size = 3139010, upload-time = "2025-10-28T18:31:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/5a/70/5c52d0dd2d8d5287b4e27724f022f7229d4c45fe0f75f53d659f67d408f6/ruff_format-0.4.1-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00e0a83bd9a7acc8d50a63d8057441085d7498e94b130377082bea4cc57fdc62", size = 2437646, upload-time = "2025-10-28T18:31:15.862Z" }, + { url = "https://files.pythonhosted.org/packages/66/99/b89a34911e7287505e13efa487f2488e9a537d2429b37f465c94ea6612bf/ruff_format-0.4.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9086e8efea0925699b1c2a649922b936356d88f64c36b859aac2ed6033991042", size = 2338267, upload-time = "2025-10-28T18:31:21.419Z" }, + { url = "https://files.pythonhosted.org/packages/ff/02/92178e6b14b93f7dfed57c6c5f249ad7b858218aaf55f303f932d04d6459/ruff_format-0.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:be26579b974401f8e6cadd4837bdb4033696740758973c43dc470bd8404abf0a", size = 2408411, upload-time = "2025-10-28T18:31:18.593Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/61f41a959595adfb00f60c9a8fcbb0b5d32c69d32cafbdf807d38cd543a9/ruff_format-0.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:95f3c7dee3067616aaeb1d3e08968ea1d71e030c1c9704524ad91149df1cbda2", size = 2460360, upload-time = "2025-10-28T18:31:30.19Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1e/d32bea94d19e1a5df9cbb581ffe46b25d72f2f13dc380ebc301366dd0791/ruff_format-0.4.1-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c00576aac8e66548d778116be4e8d83abf994303c9426d2d9fa3ee47e952ebeb", size = 2492780, upload-time = "2025-10-28T18:31:32.804Z" }, + { url = "https://files.pythonhosted.org/packages/a8/71/b6d9b84cbd716a2c968c9cfe365070d2f5411e448287ed925d9c8786bade/ruff_format-0.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15ad2649a213a78f907893a42712381e7a0f66107e5d5f48e9891657a5147852", size = 2498606, upload-time = "2025-10-28T18:31:35.818Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2e/3f40d244fdd5bdbe993f55d46e2285900d4b03f0a50fdcd8280ce8635209/ruff_format-0.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a322cd9062664d0461efc804418b349c30a68e1e58c8794b1e9b28aa57ee02a2", size = 2509832, upload-time = "2025-10-28T18:31:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/3253d7f4354a104e1e79110d740f4293042fd399981c59653a8353dc450a/ruff_format-0.4.1-cp38-abi3-win32.whl", hash = "sha256:66524a2088eb0f2bb95f297ad8bda2bec0143e9d690bbce76a2b326b4e668968", size = 1823361, upload-time = "2025-10-28T18:31:44.07Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/7f20db74ff5156900852077026771bd20f3ca1ba2a4987d98e3385c13ad8/ruff_format-0.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:3f5360ebfe9465c4bb803dd69c6cdc38610860ee9087b40ae038a595c4779653", size = 1927799, upload-time = "2025-10-28T18:31:41.716Z" }, ] [[package]] @@ -2089,16 +2079,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.35.3" +version = "20.35.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] [[package]]