From 612354ab5819fede4af8a2d2e9981ae5a532a6af Mon Sep 17 00:00:00 2001 From: Alexandre Tresallet Date: Thu, 22 Jan 2026 15:38:36 +0100 Subject: [PATCH] feat(normalization): Refactor imports and add new models for all missings strategy --- .gitignore | 1 + agentic_mesh_protocol-0.2.2-py3-none-any.whl | Bin 0 -> 191111 bytes docker/Dockerfile.test | 1 + docker/entrypoint-test.sh | 3 + examples/Examples.md | 4 +- .../modules/archetype_with_tools_module.py | 12 +- examples/modules/cpu_intensive_module.py | 2 +- examples/modules/dynamic_setup_module.py | 6 +- examples/modules/text_transform_module.py | 10 +- examples/services/filesystem_module.py | 14 +- examples/services/storage_module.py | 10 +- examples/start_grpc_client.py | 33 +- examples/start_grpc_client_config.py | 39 +- pyproject.toml | 13 +- .../core/job_manager/single_job_manager.py | 2 +- .../core/job_manager/taskiq_broker.py | 4 +- .../core/job_manager/taskiq_job_manager.py | 2 +- .../core/task_manager/base_task_manager.py | 4 +- .../core/task_manager/task_executor.py | 10 +- src/digitalkin/exception/__init__.py | 1 + src/digitalkin/exception/cost.py | 5 + src/digitalkin/exception/filesystem.py | 5 + .../exceptions.py => exception/registry.py} | 0 src/digitalkin/exception/setup.py | 9 + src/digitalkin/exception/storage.py | 5 + src/digitalkin/exception/user_profile.py | 1 + src/digitalkin/grpc_servers/module_server.py | 2 +- .../grpc_servers/module_servicer.py | 254 ++++--- .../grpc_servers/utils/grpc_error_handler.py | 2 +- src/digitalkin/mixins/chat_history_mixin.py | 2 +- src/digitalkin/mixins/cost_mixin.py | 16 +- src/digitalkin/mixins/file_history_mixin.py | 2 +- src/digitalkin/mixins/filesystem_mixin.py | 10 +- src/digitalkin/mixins/storage_mixin.py | 12 +- src/digitalkin/models/base_enum.py | 67 ++ src/digitalkin/models/base_strategy.py | 107 +++ src/digitalkin/models/grpc_servers/models.py | 2 +- .../models/module/module_context.py | 2 +- src/digitalkin/models/module/setup_types.py | 2 +- src/digitalkin/models/module/tool_cache.py | 10 +- .../models/module/tool_reference.py | 2 +- src/digitalkin/models/services/cost.py | 74 +- src/digitalkin/models/services/filesystem.py | 88 +++ src/digitalkin/models/services/modules.py | 42 ++ src/digitalkin/models/services/registry.py | 77 -- src/digitalkin/models/services/setup.py | 75 ++ src/digitalkin/models/services/storage.py | 26 + src/digitalkin/modules/_base_module.py | 10 +- src/digitalkin/modules/trigger_handler.py | 2 +- .../triggers/healthcheck_ping_trigger.py | 8 +- .../triggers/healthcheck_services_trigger.py | 8 +- .../triggers/healthcheck_status_trigger.py | 8 +- src/digitalkin/services/agent/__init__.py | 2 +- .../{default_agent.py => agent_default.py} | 4 +- .../services/agent/agent_strategy.py | 69 +- src/digitalkin/services/base_strategy.py | 25 - .../services/communication/__init__.py | 4 +- ...munication.py => communication_default.py} | 33 +- ...communication.py => communication_grpc.py} | 78 ++- .../communication/communication_strategy.py | 87 ++- src/digitalkin/services/cost/__init__.py | 9 +- .../cost/{default_cost.py => cost_default.py} | 144 ++-- .../cost/{grpc_cost.py => cost_grpc.py} | 232 +++---- src/digitalkin/services/cost/cost_strategy.py | 171 +++-- .../services/filesystem/__init__.py | 4 +- ...lt_filesystem.py => filesystem_default.py} | 251 +++---- .../services/filesystem/filesystem_grpc.py | 255 +++++++ .../filesystem/filesystem_strategy.py | 187 ++--- .../services/filesystem/grpc_filesystem.py | 324 --------- src/digitalkin/services/identity/__init__.py | 2 +- ...efault_identity.py => identity_default.py} | 6 +- .../services/identity/identity_strategy.py | 65 +- src/digitalkin/services/registry/__init__.py | 18 +- .../services/registry/default_registry.py | 166 ----- .../services/registry/registry_default.py | 133 ++++ .../{grpc_registry.py => registry_grpc.py} | 249 +++---- .../services/registry/registry_models.py | 15 - .../services/registry/registry_strategy.py | 133 +++- .../services/setup/default_setup.py | 234 ------- src/digitalkin/services/setup/grpc_setup.py | 378 ---------- .../services/setup/setup_default.py | 114 +++ src/digitalkin/services/setup/setup_grpc.py | 165 +++++ .../services/setup/setup_strategy.py | 145 ++-- .../services/setup/version/__init__.py | 1 + .../setup/version/setup_version_default.py | 126 ++++ .../setup/version/setup_version_grpc.py | 162 +++++ .../setup/version/setup_version_strategy.py | 107 +++ src/digitalkin/services/snapshot/__init__.py | 2 +- .../services/snapshot/default_snapshot.py | 39 -- .../services/snapshot/snapshot_default.py | 41 ++ .../services/snapshot/snapshot_strategy.py | 82 ++- src/digitalkin/services/storage/__init__.py | 4 +- .../services/storage/grpc_storage.py | 225 ------ ...{default_storage.py => storage_default.py} | 178 +++-- .../services/storage/storage_grpc.py | 179 +++++ .../services/storage/storage_strategy.py | 300 +++----- .../task_manager/default_task_manager.py | 16 +- .../task_manager/grpc_task_manager.py | 22 +- .../services/user_profile/__init__.py | 4 +- ...ser_profile.py => user_profile_default.py} | 10 +- ...c_user_profile.py => user_profile_grpc.py} | 29 +- .../user_profile/user_profile_strategy.py | 76 +- src/digitalkin/utils/arg_parser.py | 8 +- .../utils/development_mode_action.py | 6 +- src/digitalkin/utils/llm_ready_schema.py | 4 +- src/digitalkin/utils/schema_splitter.py | 8 +- taskfile.yaml | 247 +++++-- tests/fixtures/strict_assertions.py | 2 +- tests/grpc_server/test_module_service.py | 138 ++-- tests/mixins/test_chat_history_mixin.py | 16 +- tests/mixins/test_file_history_mixin.py | 14 +- tests/modules/_test_base_module.py | 10 +- tests/modules/test_format_methods.py | 10 +- tests/modules/test_tool_cache.py | 67 +- tests/modules/test_tool_reference.py | 79 ++- tests/performances/load_taskiq_testing.py | 42 +- tests/services/__init__.py | 0 tests/services/cost/mock_cost_servicer.py | 183 ++--- tests/services/cost/test_cost_limits.py | 117 ++-- tests/services/cost/test_cost_stress.py | 151 ++-- tests/services/cost/test_grpc_cost.py | 354 +++------- .../filesystem/mock_filesystem_servicer.py | 236 ++++--- .../filesystem/test_default_filesystem.py | 153 ++-- .../filesystem/test_grpc_filesystem.py | 298 ++++---- .../registry/mock_registry_servicer.py | 131 ++-- tests/services/registry/test_grpc_registry.py | 140 ++-- tests/services/setup/__init__.py | 0 tests/services/setup/mock_setup_servicer.py | 199 ++---- tests/services/setup/test_grpc_setup.py | 618 ++--------------- tests/services/setup/version/__init__.py | 0 .../version/mock_setup_version_servicer.py | 170 +++++ .../setup/version/test_grpc_setup_version.py | 656 ++++++++++++++++++ .../services/storage/mock_storage_servicer.py | 226 +++--- tests/services/storage/test_grpc_storage.py | 308 ++++---- .../storage/test_storage_strategy_locks.py | 75 +- .../task_manager/test_grpc_task_manager.py | 30 +- .../test_shared_poller_advanced.py | 6 +- .../mock_user_profile_servicer.py | 29 +- .../user_profile/test_grpc_user_profile.py | 124 ++-- uv.lock | 642 +++++++++-------- 140 files changed, 6281 insertions(+), 5627 deletions(-) create mode 100644 agentic_mesh_protocol-0.2.2-py3-none-any.whl create mode 100644 src/digitalkin/exception/__init__.py create mode 100644 src/digitalkin/exception/cost.py create mode 100644 src/digitalkin/exception/filesystem.py rename src/digitalkin/{services/registry/exceptions.py => exception/registry.py} (100%) create mode 100644 src/digitalkin/exception/setup.py create mode 100644 src/digitalkin/exception/storage.py create mode 100644 src/digitalkin/exception/user_profile.py create mode 100644 src/digitalkin/models/base_enum.py create mode 100644 src/digitalkin/models/base_strategy.py create mode 100644 src/digitalkin/models/services/filesystem.py create mode 100644 src/digitalkin/models/services/modules.py delete mode 100644 src/digitalkin/models/services/registry.py create mode 100644 src/digitalkin/models/services/setup.py rename src/digitalkin/services/agent/{default_agent.py => agent_default.py} (80%) delete mode 100644 src/digitalkin/services/base_strategy.py rename src/digitalkin/services/communication/{default_communication.py => communication_default.py} (71%) rename src/digitalkin/services/communication/{grpc_communication.py => communication_grpc.py} (80%) rename src/digitalkin/services/cost/{default_cost.py => cost_default.py} (59%) rename src/digitalkin/services/cost/{grpc_cost.py => cost_grpc.py} (54%) rename src/digitalkin/services/filesystem/{default_filesystem.py => filesystem_default.py} (64%) create mode 100644 src/digitalkin/services/filesystem/filesystem_grpc.py delete mode 100644 src/digitalkin/services/filesystem/grpc_filesystem.py rename src/digitalkin/services/identity/{default_identity.py => identity_default.py} (55%) delete mode 100644 src/digitalkin/services/registry/default_registry.py create mode 100644 src/digitalkin/services/registry/registry_default.py rename src/digitalkin/services/registry/{grpc_registry.py => registry_grpc.py} (59%) delete mode 100644 src/digitalkin/services/registry/registry_models.py delete mode 100644 src/digitalkin/services/setup/default_setup.py delete mode 100644 src/digitalkin/services/setup/grpc_setup.py create mode 100644 src/digitalkin/services/setup/setup_default.py create mode 100644 src/digitalkin/services/setup/setup_grpc.py create mode 100644 src/digitalkin/services/setup/version/__init__.py create mode 100644 src/digitalkin/services/setup/version/setup_version_default.py create mode 100644 src/digitalkin/services/setup/version/setup_version_grpc.py create mode 100644 src/digitalkin/services/setup/version/setup_version_strategy.py delete mode 100644 src/digitalkin/services/snapshot/default_snapshot.py create mode 100644 src/digitalkin/services/snapshot/snapshot_default.py delete mode 100644 src/digitalkin/services/storage/grpc_storage.py rename src/digitalkin/services/storage/{default_storage.py => storage_default.py} (62%) create mode 100644 src/digitalkin/services/storage/storage_grpc.py rename src/digitalkin/services/user_profile/{default_user_profile.py => user_profile_default.py} (74%) rename src/digitalkin/services/user_profile/{grpc_user_profile.py => user_profile_grpc.py} (63%) create mode 100644 tests/services/__init__.py create mode 100644 tests/services/setup/__init__.py create mode 100644 tests/services/setup/version/__init__.py create mode 100644 tests/services/setup/version/mock_setup_version_servicer.py create mode 100644 tests/services/setup/version/test_grpc_setup_version.py diff --git a/.gitignore b/.gitignore index fdcb008d..c8d9764d 100644 --- a/.gitignore +++ b/.gitignore @@ -188,5 +188,6 @@ cython_debug/ requirements.txt certs/ .report.json +.DS_Store CLAUDE.md \ No newline at end of file diff --git a/agentic_mesh_protocol-0.2.2-py3-none-any.whl b/agentic_mesh_protocol-0.2.2-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..5d44b29aa3020158ef29dc9500b2d4938acdb1c3 GIT binary patch literal 191111 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/dev/null || uv pip install --system /app/dist/agentic_mesh_protocol-*.tar.gz + if [ -n "$TEST_MARKER" ]; then pytest "${TEST_SELECTOR:-tests/}" -m "$TEST_MARKER" ${PYTEST_ARGS:-} || exit_code=$? else diff --git a/examples/Examples.md b/examples/Examples.md index 65396fac..34e0c101 100644 --- a/examples/Examples.md +++ b/examples/Examples.md @@ -232,7 +232,7 @@ Create input data and start module execution: ```python # Get module schemas -input_class, output_class, setup_class = await get_module_schemas(module_stub, module.module_id) +input_class, output_class, setup_class = await get_module_schemas(module_stub, module.id) # Create input data using the schema input_data = input_class( @@ -283,7 +283,7 @@ The system includes utilities to convert between protocol buffer schema definiti def json_to_pydantic(json_schema: Message) -> type[BaseModel]: """Convert a protobuf JSON schema message to a Pydantic model.""" model_dict = json_format.MessageToDict(json_schema) - return dict_to_pydantic_cached(model_dict, model_dict.get("title", "DynamicModel")) + return dict_to_pydantic_cached(model_dict, model_dict.list("title", "DynamicModel")) ``` This allows dynamic creation of appropriate models for interacting with modules. diff --git a/examples/modules/archetype_with_tools_module.py b/examples/modules/archetype_with_tools_module.py index ddc4661b..7d35d9a6 100644 --- a/examples/modules/archetype_with_tools_module.py +++ b/examples/modules/archetype_with_tools_module.py @@ -184,9 +184,9 @@ async def run( # Get search tool from cache and call via call_module_by_id search_info = self.context.tool_cache.get("search_tool") if search_info: - tools_used.append(f"search:{search_info.module_id}") + tools_used.append(f"search:{search_info.id}") async for response in self.context.call_module_by_id( - module_id=search_info.module_id, + module_id=search_info.id, input_data={"query": input_data.payload.user_prompt}, setup_id=self.context.session.setup_id, mission_id=self.context.session.mission_id, @@ -196,9 +196,9 @@ async def run( # Get calculator tool from cache calc_info = self.context.tool_cache.get("calculator_tool") if calc_info: - tools_used.append(f"calculator:{calc_info.module_id}") + tools_used.append(f"calculator:{calc_info.id}") async for response in self.context.call_module_by_id( - module_id=calc_info.module_id, + module_id=calc_info.id, input_data={"expression": "2 + 2"}, setup_id=self.context.session.setup_id, mission_id=self.context.session.mission_id, @@ -211,9 +211,9 @@ async def run( registry=self.context.registry, ) if dynamic_info: - tools_used.append(f"dynamic:{dynamic_info.module_id}") + tools_used.append(f"dynamic:{dynamic_info.id}") async for response in self.context.call_module_by_id( - module_id=dynamic_info.module_id, + module_id=dynamic_info.id, input_data={"prompt": input_data.payload.user_prompt}, setup_id=self.context.session.setup_id, mission_id=self.context.session.mission_id, diff --git a/examples/modules/cpu_intensive_module.py b/examples/modules/cpu_intensive_module.py index b80dff17..7a75ad59 100644 --- a/examples/modules/cpu_intensive_module.py +++ b/examples/modules/cpu_intensive_module.py @@ -7,9 +7,9 @@ from digitalkin.grpc_servers.utils.models import ClientConfig, SecurityMode, ServerConfig, ServerMode from pydantic import BaseModel, Field +from digitalkin.models.services.setup import SetupData from digitalkin.modules._base_module import BaseModule from digitalkin.services.services_models import ServicesStrategy -from digitalkin.services.setup.setup_strategy import SetupData # Configure logging with clear formatting logging.basicConfig( diff --git a/examples/modules/dynamic_setup_module.py b/examples/modules/dynamic_setup_module.py index 04ac18b2..e4f9153c 100644 --- a/examples/modules/dynamic_setup_module.py +++ b/examples/modules/dynamic_setup_module.py @@ -293,7 +293,7 @@ async def demonstrate_dynamic_schema() -> None: schema_no_force = model_no_force.model_json_schema() # Check if enum is present - model_name_schema = schema_no_force.get("properties", {}).get("model_name", {}) + model_name_schema = schema_no_force.list("properties", {}).list("model_name", {}) if "enum" in model_name_schema: pass @@ -307,11 +307,11 @@ async def demonstrate_dynamic_schema() -> None: schema_with_force = model_with_force.model_json_schema() # Check enum values after force - model_name_schema = schema_with_force.get("properties", {}).get("model_name", {}) + model_name_schema = schema_with_force.list("properties", {}).list("model_name", {}) if "enum" in model_name_schema: pass - language_schema = schema_with_force.get("properties", {}).get("language", {}) + language_schema = schema_with_force.list("properties", {}).list("language", {}) if "enum" in language_schema: pass diff --git a/examples/modules/text_transform_module.py b/examples/modules/text_transform_module.py index bc0743f5..a5c0bdb7 100644 --- a/examples/modules/text_transform_module.py +++ b/examples/modules/text_transform_module.py @@ -5,11 +5,11 @@ from typing import Any, ClassVar from digitalkin.grpc_servers.utils.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.services.setup.setup_models import SetupData +from digitalkin.services.storage.storage_models import DataType, StorageRecord from pydantic import BaseModel from digitalkin.modules._base_module import BaseModule -from digitalkin.services.setup.setup_strategy import SetupData -from digitalkin.services.storage.storage_strategy import DataType, StorageRecord # Configure logging with clear formatting logging.basicConfig( @@ -114,7 +114,7 @@ async def initialize(self, setup_data: SetupData) -> None: self.capabilities, ) - self.db_id = await self.storage.store( + self.db_id = await self.storage.create( "monitor", { "module": self.metadata["name"], @@ -173,7 +173,7 @@ async def run( transformed, ) - monitor_obj: StorageRecord | None = await self.storage.read("monitor") + monitor_obj: StorageRecord | None = await self.storage.get("monitor") if monitor_obj is None: logger.error("Monitor object not found in storage.") break @@ -194,7 +194,7 @@ async def cleanup(self) -> None: Use it to close connections, free resources, etc. """ logger.info(f"Cleaning up module {self.metadata['name']}") - monitor_obj = await self.storage.read("monitor") + monitor_obj = await self.storage.get("monitor") if monitor_obj is None: logger.error("Monitor object not found in storage.") return diff --git a/examples/services/filesystem_module.py b/examples/services/filesystem_module.py index 5ec40f6d..f28123b7 100644 --- a/examples/services/filesystem_module.py +++ b/examples/services/filesystem_module.py @@ -5,12 +5,12 @@ from collections.abc import Callable from typing import Any +from digitalkin.services.filesystem.filesystem_models import FileFilter, UploadFileData from pydantic import BaseModel, Field from digitalkin.logger import logger from digitalkin.models.module import ModuleStatus from digitalkin.modules.archetype_module import ArchetypeModule -from digitalkin.services.filesystem.filesystem_strategy import FileFilter, UploadFileData from digitalkin.services.services_config import ServicesConfig from digitalkin.services.services_models import ServicesMode @@ -118,13 +118,13 @@ async def run( file = UploadFileData( content=b"%s\n%s" % (processed_message.encode(), str(processed_number).encode()), name="example_output.txt", - file_type="text/plain", + type="text/plain", content_type="text/plain", metadata={"example_key": "example_value"}, replace_if_exists=True, ) - records, uploaded, failed = await self.filesystem.upload_files(files=[file]) + records, uploaded, failed = await self.filesystem.upload(files=[file]) for record in records: logger.info("Uploaded file: %s, uploaded: %d, failed: %d", record, uploaded, failed) logger.info("Stored file with ID: %s", record.id) @@ -175,20 +175,20 @@ def callback(result) -> None: # Check the storage if module.status == ModuleStatus.STOPPED: - files, _nb_results = await module.filesystem.get_files( + files, _nb_results = await module.filesystem.list( filters=FileFilter(name="example_output.txt", context="test-mission-123"), ) for file in files: - await module.filesystem.update_file(file.id, file_type="updated") + await module.filesystem.update(file.id, type="updated") # module.filesystem.delete_files(filters=FileFilter(name="example_output.txt", context="test-mission-123"), permanent=True) logger.info("Retrieved file: %s with ID: %s", file.name, file.id) try: - file_record = await module.filesystem.get_file(file_id=file.id, include_content=True) + file_record = await module.filesystem.list(file_id=file.id, include_content=True) if file_record: logger.info("File ID: %s", file_record.id) logger.info("File name: %s", file_record.name) - logger.info("File type: %s", file_record.file_type) + logger.info("File type: %s", file_record.type) logger.info("File status: %s", file_record.status) logger.info("File content: %s", file_record.content.decode()) except Exception: diff --git a/examples/services/storage_module.py b/examples/services/storage_module.py index 07b9ee27..1f9b16d2 100644 --- a/examples/services/storage_module.py +++ b/examples/services/storage_module.py @@ -14,7 +14,7 @@ from digitalkin.services.services_models import ServicesMode if TYPE_CHECKING: - from digitalkin.services.storage.storage_strategy import StorageRecord + from digitalkin.services.storage.storage_models import StorageRecord class ExampleInput(BaseModel): @@ -134,7 +134,7 @@ async def run( ) # Store the output data in storage - storage_id = await self.storage.store( + storage_id = await self.storage.create( collection="example", record_id="example_outputs", data=output_data.model_dump(), data_type="OUTPUT" ) @@ -176,7 +176,7 @@ def callback(result) -> None: # Check the storage if module.status == ModuleStatus.STOPPED: - result: StorageRecord = await module.storage.read("example", "example_outputs") + result: StorageRecord = await module.storage.get("example", "example_outputs") if result: pass @@ -189,10 +189,10 @@ async def test_storage_directly() -> None: ) # Create a test record - await storage.store("example", "test_table", {"test_key": "test_value"}, "OUTPUT") + await storage.create("example", "test_table", {"test_key": "test_value"}, "OUTPUT") # Retrieve the record - retrieved = await storage.read("example", "test_table") + retrieved = await storage.get("example", "test_table") if retrieved: pass diff --git a/examples/start_grpc_client.py b/examples/start_grpc_client.py index 6bfa1fe4..3448de9d 100644 --- a/examples/start_grpc_client.py +++ b/examples/start_grpc_client.py @@ -22,10 +22,9 @@ from typing import Any import grpc - # Import gRPC protobuf generated classes -from agentic_mesh_protocol.module.v1 import information_pb2, lifecycle_pb2, module_service_pb2_grpc -from agentic_mesh_protocol.module_registry.v1 import discover_pb2, module_registry_service_pb2_grpc +from agentic_mesh_protocol.module.v1 import module_dto_pb2, module_service_pb2_grpc +from agentic_mesh_protocol.registry.v1 import registry_dto_pb2, registry_service_pb2_grpc from google.protobuf import json_format from google.protobuf.message import Message from pydantic import BaseModel, create_model @@ -86,12 +85,12 @@ def dict_to_pydantic(data: str, model_name: str = "DynamicModel") -> type[BaseMo raise ValueError(msg) properties = data_dict["properties"] - required_fields = set(data_dict.get("required", [])) + required_fields = set(data_dict.list("required", [])) field_definitions = {} # Create field definitions for the Pydantic model for field_name, field_info in properties.items(): - field_type_str = field_info.get("type", "string") + field_type_str = field_info.list("type", "string") python_type = TYPE_MAPPING.get(field_type_str, Any) # Mark required fields with ellipsis (...) as required @@ -121,7 +120,7 @@ def dict_to_pydantic_cached( async def discover_module( registry_channel: grpc.aio.Channel, module_name: str -) -> discover_pb2.DiscoverInfoResponse | None: +) -> registry_dto_pb2.GetModuleResponse | None: """Discover a module by name from the registry. Args: @@ -132,10 +131,10 @@ async def discover_module( Module information or None if not found """ # Create registry service stub - registry_stub = module_registry_service_pb2_grpc.ModuleRegistryServiceStub(registry_channel) + registry_stub = registry_service_pb2_grpc.RegistryServiceStub(registry_channel) # Create discover request - request = discover_pb2.DiscoverSearchRequest(name=module_name) + request = registry_dto_pb2.SearchModulesRequest(name=module_name) try: # Send request to registry @@ -167,9 +166,9 @@ async def get_module_schemas( Tuple of (input_class, output_class, setup_class) Pydantic models """ # Create requests for each schema - input_request = information_pb2.GetModuleInputRequest(module_id=module_id) - output_request = information_pb2.GetModuleOutputRequest(module_id=module_id) - setup_request = information_pb2.GetModuleSetupRequest(module_id=module_id) + input_request = module_dto_pb2.GetModuleInputRequest(id=module_id) + output_request = module_dto_pb2.GetModuleOutputRequest(id=module_id) + setup_request = module_dto_pb2.GetModuleSetupRequest(id=module_id) # Get schemas from module input_response = await module_stub.GetModuleInput(input_request) @@ -196,7 +195,7 @@ async def run_client_text_transform() -> None: logger.error("Module not found. Make sure the module server is running.") return - logger.info("Found module: %s (ID: %s)", module.metadata.name, module.module_id) + logger.info("Found module: %s (ID: %s)", module.result.module_descriptor.name, module.result.module_descriptor.id) # Connect to module server async with grpc.aio.insecure_channel("localhost:50051") as module_channel: @@ -206,7 +205,7 @@ async def run_client_text_transform() -> None: module_stub = module_service_pb2_grpc.ModuleServiceStub(module_channel) # Get module schemas - input_class, output_class, setup_class = await get_module_schemas(module_stub, module.module_id) + input_class, output_class, setup_class = await get_module_schemas(module_stub, module.result.module_descriptor.id) logger.info( "Retrieved module schemas: %s, %s and %s", @@ -227,7 +226,7 @@ async def run_client_text_transform() -> None: ) # Create start module request - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( input=input_data.model_dump(), setup_id=setup_id, mission_id=mission_id ) @@ -262,7 +261,7 @@ async def run_client_llm() -> None: logger.error("Module not found. Make sure the module server is running.") return - logger.info("Found module: %s (ID: %s)", module.metadata.name, module.module_id) + logger.info("Found module: %s (ID: %s)", module.result.module_descriptor.name, module.result.module_descriptor.id) # Connect to module server async with grpc.aio.insecure_channel("localhost:50055") as module_channel: @@ -272,7 +271,7 @@ async def run_client_llm() -> None: module_stub = module_service_pb2_grpc.ModuleServiceStub(module_channel) # Get module schemas - input_class, output_class, setup_class = await get_module_schemas(module_stub, module.module_id) + input_class, output_class, setup_class = await get_module_schemas(module_stub, module.result.module_descriptor.id) logger.info( "Retrieved module schemas: %s, %s and %s", @@ -290,7 +289,7 @@ async def run_client_llm() -> None: input_data = input_class(prompt="Give me details about agentic mesh current advancement") # Create start module request - lifecycle_pb2.StartModuleRequest(input=input_data.model_dump(), setup_id=setup_id, mission_id=mission_id) + module_dto_pb2.StartModuleRequest(input=input_data.model_dump(), setup_id=setup_id, mission_id=mission_id) logger.info("Starting module with input: %s", input_data.model_dump()) diff --git a/examples/start_grpc_client_config.py b/examples/start_grpc_client_config.py index aa87619a..3f476fd3 100644 --- a/examples/start_grpc_client_config.py +++ b/examples/start_grpc_client_config.py @@ -22,11 +22,10 @@ from typing import Any import grpc - # Import gRPC protobuf generated classes -from agentic_mesh_protocol.module.v1 import information_pb2, lifecycle_pb2, module_service_pb2_grpc -from agentic_mesh_protocol.module_registry.v1 import discover_pb2, module_registry_service_pb2_grpc -from agentic_mesh_protocol.setup.v1 import setup_pb2 +from agentic_mesh_protocol.module.v1 import module_dto_pb2, module_service_pb2_grpc +from agentic_mesh_protocol.registry.v1 import registry_dto_pb2, registry_service_pb2_grpc +from agentic_mesh_protocol.setup.v1 import setup_messages_pb2 from google.protobuf import json_format, struct_pb2 from google.protobuf.message import Message from pydantic import BaseModel, create_model @@ -87,12 +86,12 @@ def dict_to_pydantic(data: str, model_name: str = "DynamicModel") -> type[BaseMo raise ValueError(msg) properties = data_dict["properties"] - required_fields = set(data_dict.get("required", [])) + required_fields = set(data_dict.list("required", [])) field_definitions = {} # Create field definitions for the Pydantic model for field_name, field_info in properties.items(): - field_type_str = field_info.get("type", "string") + field_type_str = field_info.list("type", "string") python_type = TYPE_MAPPING.get(field_type_str, Any) # Mark required fields with ellipsis (...) as required @@ -120,9 +119,9 @@ def dict_to_pydantic_cached( return dict_to_pydantic(data_str, model_name) -async def discover_module( +async def get_module( registry_channel: grpc.aio.Channel, module_name: str -) -> discover_pb2.DiscoverInfoResponse | None: +) -> registry_dto_pb2.GetModuleResponse | None: """Discover a module by name from the registry. Args: @@ -132,11 +131,12 @@ async def discover_module( Returns: Module information or None if not found """ + # todo: a check # Create registry service stub - registry_stub = module_registry_service_pb2_grpc.ModuleRegistryServiceStub(registry_channel) + registry_stub = registry_service_pb2_grpc.RegistryServiceStub(registry_channel) # Create discover request - request = discover_pb2.DiscoverSearchRequest(name=module_name) + request = registry_dto_pb2.SearchModulesRequest(name=module_name) try: # Send request to registry @@ -168,9 +168,9 @@ async def get_module_schemas( Tuple of (input_class, output_class, setup_class) Pydantic models """ # Create requests for each schema - input_request = information_pb2.GetModuleInputRequest(module_id=module_id) - output_request = information_pb2.GetModuleOutputRequest(module_id=module_id) - setup_request = information_pb2.GetModuleSetupRequest(module_id=module_id) + input_request = module_dto_pb2.GetModuleInputRequest(id=module_id) + output_request = module_dto_pb2.GetModuleOutputRequest(id=module_id) + setup_request = module_dto_pb2.GetModuleSetupRequest(id=module_id) # Get schemas from module input_response = await module_stub.GetModuleInput(input_request) @@ -192,12 +192,13 @@ async def run_client_llm() -> None: logger.info("Connecting to registry server at localhost:50052") # Find the module - module = await discover_module(registry_channel, "OpenAIToolModule") + module = await get_module(registry_channel, "OpenAIToolModule") if not module: logger.error("Module not found. Make sure the module server is running.") return - logger.info("Found module: %s (ID: %s)", module.metadata.name, module.module_id, extra={"module_info": module}) + logger.info("Found module: %s (ID: %s)", module.result.module_descriptor.name, module.result.module_descriptor.id, extra={"module_info": + module}) # Connect to module server async with grpc.aio.insecure_channel("localhost:50055") as module_channel: @@ -207,7 +208,7 @@ async def run_client_llm() -> None: module_stub = module_service_pb2_grpc.ModuleServiceStub(module_channel) # Get module schemas - input_class, output_class, setup_class = await get_module_schemas(module_stub, module.module_id) + input_class, output_class, setup_class = await get_module_schemas(module_stub, module.result.module_descriptor.id) logger.info( "Retrieved module schemas: %s, %s and %s", @@ -232,7 +233,7 @@ async def run_client_llm() -> None: max_tokens=1000, ) - config_setup_request = information_pb2.GetConfigSetupModuleRequest(module_id=module.module_id) + config_setup_request = module_dto_pb2.GetConfigSetupModuleRequest(id=module.result.module_descriptor.id) config_setup_response = await module_stub.GetConfigSetupModule(config_setup_request) config_setup_class = json_to_pydantic(config_setup_response.config_setup_schema) @@ -243,8 +244,8 @@ async def run_client_llm() -> None: ] ).model_dump() - request = lifecycle_pb2.ConfigSetupModuleRequest( - setup_version=setup_pb2.SetupVersion( + request = module_dto_pb2.ConfigSetupModuleRequest( + setup_version=setup_messages_pb2.SetupVersion( id="setup_versions:0", setup_id="setups:0", version="0.1.0", diff --git a/pyproject.toml b/pyproject.toml index c1330dab..4e7c8e93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,13 @@ ] dependencies = [ - "agentic-mesh-protocol==0.2.3", + "agentic-mesh-protocol==0.2.2", "anyio==4.12.1", "grpcio-health-checking==1.78.0", "grpcio-reflection==1.78.0", "grpcio-status==1.78.0", "pydantic==2.12.5", + "surrealdb>=1.0.7", ] version = "0.3.2" @@ -202,14 +203,21 @@ ignore = [ "ANN401", # Allow typing.Any — gRPC stubs, callbacks, and dynamic APIs require it + "ARG002", # Allow unused method arguments — gRPC servicer signatures require them "COM812", # Disable because of formatter incompatibility + "D100", # Allow missing module docstring + "D102", # Allow missing public method docstring + "D104", # Allow missing public package docstring + "D107", # Allow missing __init__ docstring "DOC502", # Allow extraneous-exception in docstring "F401", # Allow unused imports — used for availability checks (e.g. taskiq) "N802", # Allow PascalCase methods — gRPC servicer convention "PLC0415", # Allow lazy imports — needed to break circular dependencies + "PLR6301", # Allow methods that could be functions — interface compliance "PLW3201", # Allow __get_pydantic_core_schema__ — Pydantic dunder hook "S105", # Hardcoded-password "S403", # Allow pickle import — required for Taskiq serialization + "SLF001", # Allow private member access — internal SDK wiring ] fixable = [ "ALL" ] @@ -296,3 +304,6 @@ "taskiq: marks tests for Taskiq distributed job execution", "validation: marks tests for input validation and schema checking", ] + +[tool.uv.sources] + agentic-mesh-protocol = { path = "agentic_mesh_protocol-0.2.2-py3-none-any.whl" } diff --git a/src/digitalkin/core/job_manager/single_job_manager.py b/src/digitalkin/core/job_manager/single_job_manager.py index 7f203145..e38420a9 100644 --- a/src/digitalkin/core/job_manager/single_job_manager.py +++ b/src/digitalkin/core/job_manager/single_job_manager.py @@ -161,7 +161,7 @@ async def add_to_queue(self, job_id: str, output_data: DataModel | ModuleCodeMod logger.debug("Queue write rejected - session not found", extra={"job_id": job_id}) return - async with session._write_lock: # noqa: SLF001 + async with session._write_lock: # Re-check after acquiring lock — session may have been cleaned up if self.tasks_sessions.get(job_id) is None: logger.debug("Queue write rejected - session removed during lock wait", extra={"job_id": job_id}) diff --git a/src/digitalkin/core/job_manager/taskiq_broker.py b/src/digitalkin/core/job_manager/taskiq_broker.py index 6d71ccf9..1841efb3 100644 --- a/src/digitalkin/core/job_manager/taskiq_broker.py +++ b/src/digitalkin/core/job_manager/taskiq_broker.py @@ -40,7 +40,7 @@ class PickleFormatter(TaskiqFormatter): by first converting to JSON-safe primitives, then pickling that string. """ - def dumps(self, message: TaskiqMessage) -> BrokerMessage: # Required by TaskiqFormatter interface # noqa: PLR6301 + def dumps(self, message: TaskiqMessage) -> BrokerMessage: """Dumps message from python complex object to JSON. Args: @@ -58,7 +58,7 @@ def dumps(self, message: TaskiqMessage) -> BrokerMessage: # Required by TaskiqF labels=message.labels, ) - def loads(self, message: bytes) -> TaskiqMessage: # Required by TaskiqFormatter interface # noqa: PLR6301 + def loads(self, message: bytes) -> TaskiqMessage: """Recreate Python object from bytes. Args: diff --git a/src/digitalkin/core/job_manager/taskiq_job_manager.py b/src/digitalkin/core/job_manager/taskiq_job_manager.py index b9e36d23..f19bd294 100644 --- a/src/digitalkin/core/job_manager/taskiq_job_manager.py +++ b/src/digitalkin/core/job_manager/taskiq_job_manager.py @@ -56,7 +56,7 @@ def _define_consumer() -> Consumer: async def _on_message( self, message: bytes, - message_context: MessageContext, # noqa: ARG002 #TODO + _message_context: MessageContext, # RStream callback signature ) -> None: # RStream callback signature """Internal callback: parse JSON and route to the correct job queue.""" try: diff --git a/src/digitalkin/core/task_manager/base_task_manager.py b/src/digitalkin/core/task_manager/base_task_manager.py index c2b4e1f0..ebe11ed4 100644 --- a/src/digitalkin/core/task_manager/base_task_manager.py +++ b/src/digitalkin/core/task_manager/base_task_manager.py @@ -104,7 +104,7 @@ async def _cleanup_task(self, task_id: str, mission_id: str) -> None: if session is not None: # Close stream under write lock so pending writes finish first, # then see stream_closed on their next attempt. - async with session._write_lock: # noqa: SLF001 + async with session._write_lock: session.close_stream() # Atomic pop — second concurrent caller gets None and returns @@ -312,7 +312,7 @@ async def _deferred_cleanup(self, task_id: str, mission_id: str) -> None: return try: - await asyncio.wait_for(session._stream_closed.wait(), timeout=self._stream_drain_timeout) # noqa: SLF001 + await asyncio.wait_for(session._stream_closed.wait(), timeout=self._stream_drain_timeout) except asyncio.TimeoutError: logger.warning( "Stream drain timeout, proceeding with cleanup", diff --git a/src/digitalkin/core/task_manager/task_executor.py b/src/digitalkin/core/task_manager/task_executor.py index 17909e07..993376a7 100644 --- a/src/digitalkin/core/task_manager/task_executor.py +++ b/src/digitalkin/core/task_manager/task_executor.py @@ -28,7 +28,7 @@ class TaskExecutor: _profile_output_dir: str = os.environ.get("DIGITALKIN_PROFILE_OUTPUT_DIR", "./profiles") @staticmethod - async def execute_task( # noqa: C901, PLR0915 — supervisor pattern + async def execute_task( # noqa: C901, PLR0915 task_id: str, mission_id: str, coro: Coroutine[Any, Any, None], @@ -86,8 +86,8 @@ async def signal_wrapper() -> None: setup_version_id=session.setup_version_id, action=SignalType.STOP, cancellation_reason=session.cancellation_reason, - error_message=session._last_exception, # noqa: SLF001 - exception_traceback=session._last_traceback, # noqa: SLF001 + error_message=session._last_exception, + exception_traceback=session._last_traceback, ).model_dump(exclude_none=True), ) logger.info("Signal listener ended", extra={"mission_id": mission_id, "task_id": task_id}) @@ -122,7 +122,7 @@ async def supervisor() -> None: # noqa: C901, PLR0912, PLR0915 if completed is main_task: cleanup_reason = CancellationReason.SUCCESS_CLEANUP elif completed is sig_task: - if session._signal_listener_failed: # noqa: SLF001 + if session._signal_listener_failed: cleanup_reason = CancellationReason.FAILURE_CLEANUP else: cleanup_reason = CancellationReason.SIGNAL_SERVICE_CANCEL @@ -161,7 +161,7 @@ async def supervisor() -> None: # noqa: C901, PLR0912, PLR0915 extra={"mission_id": mission_id, "task_id": task_id}, ) elif completed is sig_task: - if session._signal_listener_failed: # noqa: SLF001 + if session._signal_listener_failed: session.status = "failed" session.cancellation_reason = CancellationReason.GRPC_SERVICE_ERROR logger.error( diff --git a/src/digitalkin/exception/__init__.py b/src/digitalkin/exception/__init__.py new file mode 100644 index 00000000..d50b220b --- /dev/null +++ b/src/digitalkin/exception/__init__.py @@ -0,0 +1 @@ +"""Exception classes for DigitalKin services.""" diff --git a/src/digitalkin/exception/cost.py b/src/digitalkin/exception/cost.py new file mode 100644 index 00000000..0939f26a --- /dev/null +++ b/src/digitalkin/exception/cost.py @@ -0,0 +1,5 @@ +"""Cost service exceptions.""" + + +class CostServiceError(Exception): + """Custom exception for CostService errors.""" diff --git a/src/digitalkin/exception/filesystem.py b/src/digitalkin/exception/filesystem.py new file mode 100644 index 00000000..5c7ff277 --- /dev/null +++ b/src/digitalkin/exception/filesystem.py @@ -0,0 +1,5 @@ +"""Filesystem service exceptions.""" + + +class FilesystemServiceError(Exception): + """Base exception for Filesystem service errors.""" diff --git a/src/digitalkin/services/registry/exceptions.py b/src/digitalkin/exception/registry.py similarity index 100% rename from src/digitalkin/services/registry/exceptions.py rename to src/digitalkin/exception/registry.py diff --git a/src/digitalkin/exception/setup.py b/src/digitalkin/exception/setup.py new file mode 100644 index 00000000..1cf1ff0b --- /dev/null +++ b/src/digitalkin/exception/setup.py @@ -0,0 +1,9 @@ +"""Setup service exceptions.""" + + +class SetupServiceError(Exception): + """Base exception for Setup service errors.""" + + +class SetupVersionServiceError(Exception): + """Base exception for Setup service errors.""" diff --git a/src/digitalkin/exception/storage.py b/src/digitalkin/exception/storage.py new file mode 100644 index 00000000..d59611ac --- /dev/null +++ b/src/digitalkin/exception/storage.py @@ -0,0 +1,5 @@ +"""Storage service exceptions.""" + + +class StorageServiceError(Exception): + """Base exception for Setup service errors.""" diff --git a/src/digitalkin/exception/user_profile.py b/src/digitalkin/exception/user_profile.py new file mode 100644 index 00000000..7bcf9f22 --- /dev/null +++ b/src/digitalkin/exception/user_profile.py @@ -0,0 +1 @@ +"""User profile service exceptions.""" diff --git a/src/digitalkin/grpc_servers/module_server.py b/src/digitalkin/grpc_servers/module_server.py index 3072d677..bd0e9e65 100644 --- a/src/digitalkin/grpc_servers/module_server.py +++ b/src/digitalkin/grpc_servers/module_server.py @@ -204,7 +204,7 @@ async def _register_with_registry(self) -> None: logger.info( "Module registered successfully", extra={ - "module_id": result.module_id, + "module_id": result.id, "address": advertise_address, "port": self.server_config.port, }, diff --git a/src/digitalkin/grpc_servers/module_servicer.py b/src/digitalkin/grpc_servers/module_servicer.py index be3926d8..f3fe55ef 100644 --- a/src/digitalkin/grpc_servers/module_servicer.py +++ b/src/digitalkin/grpc_servers/module_servicer.py @@ -7,26 +7,24 @@ from typing import Any, cast import grpc -from agentic_mesh_protocol.module.v1 import ( - information_pb2, - lifecycle_pb2, - module_service_pb2_grpc, - monitoring_pb2, -) +from agentic_mesh_protocol.module.v1 import module_dto_pb2, module_messages_pb2, module_service_pb2_grpc +from agentic_mesh_protocol.pagination.v1.bulk_pb2 import OperationError from google.protobuf import json_format, struct_pb2 from pydantic import ValidationError from digitalkin.core.job_manager.base_job_manager import BaseJobManager +from digitalkin.exception.setup import SetupServiceError from digitalkin.grpc_servers.utils.exceptions import ServerError, ServicerError from digitalkin.logger import logger from digitalkin.models.core.job_manager_models import JobManagerMode -from digitalkin.models.module.module import ModuleCodeModel, ModuleStatus +from digitalkin.models.module.module import ModuleCodeModel +from digitalkin.models.services.setup import SetupVersionData from digitalkin.modules._base_module import BaseModule from digitalkin.services.registry import GrpcRegistry, RegistryStrategy from digitalkin.services.services_models import ServicesMode -from digitalkin.services.setup.default_setup import DefaultSetup -from digitalkin.services.setup.grpc_setup import GrpcSetup -from digitalkin.services.setup.setup_strategy import SetupServiceError, SetupStrategy, SetupVersionData +from digitalkin.services.setup.setup_default import DefaultSetup +from digitalkin.services.setup.setup_grpc import GrpcSetup +from digitalkin.services.setup.setup_strategy import SetupStrategy from digitalkin.utils.arg_parser import ArgParser from digitalkin.utils.development_mode_action import DevelopmentModeMappingAction @@ -36,9 +34,6 @@ class ModuleServicer(module_service_pb2_grpc.ModuleServiceServicer, ArgParser): This servicer handles interactions with a DigitalKin module. - Attributes: - module: The module instance being served. - active_jobs: Dictionary tracking active module jobs. """ args: Namespace @@ -186,7 +181,7 @@ async def _fetch_setup(self, setup_id: str, mission_id: str) -> SetupVersionData LookupError: No setup data found for setup_id. """ logger.debug("debug:_resolve_setup cache miss setup_id=%s mission_id=%s", setup_id, mission_id) - setup_data = await self.setup.get_setup({"setup_id": setup_id, "mission_id": mission_id}) + setup_data = await self.setup.get({"setup_id": setup_id, "mission_id": mission_id}) if setup_data is None: raise LookupError(setup_id) result = setup_data.current_setup_version @@ -195,9 +190,9 @@ async def _fetch_setup(self, setup_id: str, mission_id: str) -> SetupVersionData async def ConfigSetupModule( self, - request: lifecycle_pb2.ConfigSetupModuleRequest, + request: module_dto_pb2.ConfigSetupModuleRequest, context: grpc.aio.ServicerContext, - ) -> lifecycle_pb2.ConfigSetupModuleResponse: + ) -> module_dto_pb2.ConfigSetupModuleResponse: """Configure the module setup. Args: @@ -251,7 +246,8 @@ async def ConfigSetupModule( if job_id is None: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details("Failed to create module instance") - return lifecycle_pb2.ConfigSetupModuleResponse(success=False) + result = module_messages_pb2.ModuleResult(success=False) + return module_dto_pb2.ConfigSetupModuleResponse(result=result) updated_setup_data = await self.job_manager.generate_config_setup_module_response(job_id) logger.info("Setup response received", extra={"job_id": job_id}) @@ -264,7 +260,10 @@ async def ConfigSetupModule( ) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(updated_setup_data.message or "Config setup failed") - return lifecycle_pb2.ConfigSetupModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.ConfigSetupModuleResponse(result=result) if isinstance(updated_setup_data, dict) and "code" in updated_setup_data: # ModuleCodeModel was serialized to dict @@ -278,7 +277,10 @@ async def ConfigSetupModule( ) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(updated_setup_data.get("message") or "Config setup failed") - return lifecycle_pb2.ConfigSetupModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.ConfigSetupModuleResponse(result=result) logger.debug("Updated setup data", extra={"job_id": job_id, "setup_data": updated_setup_data}) @@ -296,13 +298,14 @@ async def ConfigSetupModule( struct_pb2.Struct(), ignore_unknown_fields=True, ) - return lifecycle_pb2.ConfigSetupModuleResponse(success=True, setup_version=setup_version) + result = module_messages_pb2.ModuleResult(setup_version=setup_version, success=True) + return module_dto_pb2.ConfigSetupModuleResponse(result=result) async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 self, - request: lifecycle_pb2.StartModuleRequest, + request: module_dto_pb2.StartModuleRequest, context: grpc.aio.ServicerContext, - ) -> AsyncGenerator[lifecycle_pb2.StartModuleResponse, Any]: + ) -> AsyncGenerator[module_dto_pb2.StartModuleResponse, Any]: """Start a module execution. Args: @@ -342,7 +345,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) No setup data found for setup_id" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return except SetupServiceError as e: logger.error( @@ -364,7 +370,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) Setup service unavailable: {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNAVAILABLE)), success=False + ) + yield module_dto_pb2.StartModuleResponse(job_id=None, result=result) return except ServerError as e: logger.error( @@ -385,7 +394,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) gRPC communication error with Setup service: {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNAVAILABLE)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return except ValidationError as e: logger.error( @@ -406,7 +418,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) Setup data validation failed: {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return except Exception as e: error_type = type(e).__name__ @@ -429,7 +444,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) Unexpected {error_type} during setup fetch: {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNKNOWN)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return setup_data = await self.module_class.create_setup_model(setup_version.content) @@ -472,7 +490,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) Database connection failed: {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNAVAILABLE)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return except RuntimeError as e: logger.error( @@ -491,7 +512,10 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.RESOURCE_EXHAUSTED)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return except Exception as e: error_type = type(e).__name__ @@ -514,13 +538,19 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 f"[gRPC-server:ModuleService.StartModule] (setup_id={request.setup_id}, " f"mission_id={request.mission_id}) Failed to create job: {error_type}: {e}" ) - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return if job_id is None: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details("Failed to create module instance") - yield lifecycle_pb2.StartModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result) return try: @@ -535,19 +565,26 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 logger.error("Error in output_data", extra={"message": message}) context.set_code(message["error"]["code"]) context.set_details(message["error"]["error_message"]) - yield lifecycle_pb2.StartModuleResponse(success=False, job_id=job_id) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(message["error"]["code"])), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result, job_id=job_id) break if message.get("exception", None) is not None: logger.error("Exception in output_data", extra={"message": message}) context.set_code(message["short_description"]) context.set_details(message["exception"]) - yield lifecycle_pb2.StartModuleResponse(success=False, job_id=job_id) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(message["error"]["code"])), success=False + ) + yield module_dto_pb2.StartModuleResponse(result=result, job_id=job_id) break logger.debug("Yielding message from job %s", job_id) proto = json_format.ParseDict(message, struct_pb2.Struct(), ignore_unknown_fields=True) - yield lifecycle_pb2.StartModuleResponse(success=True, output=proto, job_id=job_id) + result = module_messages_pb2.ModuleResult(success=True, output=proto) + yield module_dto_pb2.StartModuleResponse(result=result, job_id=job_id) if message.get("root", {}).get("protocol") == "end_of_stream": logger.debug( @@ -589,9 +626,9 @@ async def StartModule( # noqa: C901, PLR0911, PLR0912, PLR0915 async def StopModule( self, - request: lifecycle_pb2.StopModuleRequest, + request: module_dto_pb2.StopModuleRequest, context: grpc.ServicerContext, - ) -> lifecycle_pb2.StopModuleResponse: + ) -> module_dto_pb2.StopModuleResponse: """Stop a running module execution. Args: @@ -611,16 +648,20 @@ async def StopModule( logger.warning("Job not found for stop request", extra={"job_id": request.job_id}) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f"Job {request.job_id} not found") - return lifecycle_pb2.StopModuleResponse(success=False) + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False + ) + return module_dto_pb2.StopModuleResponse(result=result) logger.debug("Job stopped successfully", extra={"job_id": request.job_id}) - return lifecycle_pb2.StopModuleResponse(success=True) + result = module_messages_pb2.ModuleResult(success=True) + return module_dto_pb2.StopModuleResponse(result=result) async def GetModuleInput( self, - request: information_pb2.GetModuleInputRequest, + request: module_dto_pb2.GetModuleInputRequest, context: grpc.ServicerContext, - ) -> information_pb2.GetModuleInputResponse: + ) -> module_dto_pb2.GetModuleInputResponse: """Get information about the module's expected input. Args: @@ -647,27 +688,31 @@ async def GetModuleInput( logger.warning(e) context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details(str(e)) - return information_pb2.GetModuleInputResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNIMPLEMENTED)), success=False + ) + return module_dto_pb2.GetModuleInputResponse(result=result) except Exception as e: logger.exception("Failed to get input format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get input format: {e}") - return information_pb2.GetModuleInputResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetModuleInputResponse(result=result) - return information_pb2.GetModuleInputResponse( - success=True, - input_schema=input_format_struct, - ) + result = module_messages_pb2.ModuleResult(input_schema=input_format_struct, success=True) + return module_dto_pb2.GetModuleInputResponse(result=result) async def GetModuleSelectInput( self, - request: information_pb2.GetModuleSelectInputRequest, # gRPC servicer signature # noqa: ARG002 + _request: module_dto_pb2.GetModuleSelectInputRequest, # gRPC servicer signature context: grpc.ServicerContext, # gRPC servicer signature - ) -> information_pb2.GetModuleSelectInputResponse: + ) -> module_dto_pb2.GetModuleSelectInputResponse: """Get the trigger selection schema for the module. Args: - request: The get module select input request. + _request: The get module select input request. context: The gRPC context. Returns: @@ -686,18 +731,19 @@ async def GetModuleSelectInput( logger.exception("Failed to get select input format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get select input format: {e}") - return information_pb2.GetModuleSelectInputResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetModuleSelectInputResponse(result=result) - return information_pb2.GetModuleSelectInputResponse( - success=True, - select_input_schema=select_input_format_struct, - ) + result = module_messages_pb2.ModuleResult(select_input_schema=select_input_format_struct, success=True) + return module_dto_pb2.GetModuleSelectInputResponse(result=result) async def GetModuleOutput( self, - request: information_pb2.GetModuleOutputRequest, + request: module_dto_pb2.GetModuleOutputRequest, context: grpc.ServicerContext, - ) -> information_pb2.GetModuleOutputResponse: + ) -> module_dto_pb2.GetModuleOutputResponse: """Get information about the module's expected output. Args: @@ -724,23 +770,27 @@ async def GetModuleOutput( logger.warning(e) context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details(str(e)) - return information_pb2.GetModuleOutputResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNIMPLEMENTED), message=str(e)), success=False + ) + return module_dto_pb2.GetModuleOutputResponse(result=result) except Exception as e: logger.exception("Failed to get output format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get output format: {e}") - return information_pb2.GetModuleOutputResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetModuleOutputResponse(result=result) - return information_pb2.GetModuleOutputResponse( - success=True, - output_schema=output_format_struct, - ) + result = module_messages_pb2.ModuleResult(output_schema=output_format_struct, success=True) + return module_dto_pb2.GetModuleOutputResponse(result=result) async def GetModuleSetup( self, - request: information_pb2.GetModuleSetupRequest, + request: module_dto_pb2.GetModuleSetupRequest, context: grpc.ServicerContext, - ) -> information_pb2.GetModuleSetupResponse: + ) -> module_dto_pb2.GetModuleSetupResponse: """Get information about the module's setup and configuration. Args: @@ -765,23 +815,27 @@ async def GetModuleSetup( logger.warning(e) context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details(str(e)) - return information_pb2.GetModuleSetupResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNIMPLEMENTED), message=str(e)), success=False + ) + return module_dto_pb2.GetModuleSetupResponse(result=result) except Exception as e: logger.exception("Failed to get setup format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get setup format: {e}") - return information_pb2.GetModuleSetupResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetModuleSetupResponse(result=result) - return information_pb2.GetModuleSetupResponse( - success=True, - setup_schema=setup_format_struct, - ) + result = module_messages_pb2.ModuleResult(secret_schema=setup_format_struct, success=True) + return module_dto_pb2.GetModuleSetupResponse(result=result) async def GetModuleSecret( self, - request: information_pb2.GetModuleSecretRequest, + request: module_dto_pb2.GetModuleSecretRequest, context: grpc.ServicerContext, - ) -> information_pb2.GetModuleSecretResponse: + ) -> module_dto_pb2.GetModuleSecretResponse: """Get information about the module's secrets. Args: @@ -806,23 +860,27 @@ async def GetModuleSecret( logger.warning(e) context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details(str(e)) - return information_pb2.GetModuleSecretResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNIMPLEMENTED), message=str(e)), success=False + ) + return module_dto_pb2.GetModuleSecretResponse(result=result) except Exception as e: logger.exception("Failed to get secret format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get secret format: {e}") - return information_pb2.GetModuleSecretResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetModuleSecretResponse(result=result) - return information_pb2.GetModuleSecretResponse( - success=True, - secret_schema=secret_format_struct, - ) + result = module_messages_pb2.ModuleResult(secret_schema=secret_format_struct, success=True) + return module_dto_pb2.GetModuleSecretResponse(result=result) async def GetConfigSetupModule( self, - request: information_pb2.GetConfigSetupModuleRequest, + request: module_dto_pb2.GetConfigSetupModuleRequest, context: grpc.ServicerContext, - ) -> information_pb2.GetConfigSetupModuleResponse: + ) -> module_dto_pb2.GetConfigSetupModuleResponse: """Get information about the module's setup and configuration. Args: @@ -847,23 +905,27 @@ async def GetConfigSetupModule( logger.warning(e) context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details(str(e)) - return information_pb2.GetConfigSetupModuleResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNIMPLEMENTED), message=str(e)), success=False + ) + return module_dto_pb2.GetConfigSetupModuleResponse(result=result) except Exception as e: logger.exception("Failed to get config setup format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get config setup format: {e}") - return information_pb2.GetConfigSetupModuleResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetConfigSetupModuleResponse(result=result) - return information_pb2.GetConfigSetupModuleResponse( - success=True, - config_setup_schema=config_setup_format_struct, - ) + result = module_messages_pb2.ModuleResult(config_setup_schema=config_setup_format_struct, success=True) + return module_dto_pb2.GetConfigSetupModuleResponse(result=result) async def GetModuleCost( self, - request: information_pb2.GetModuleCostRequest, + request: module_dto_pb2.GetModuleCostRequest, context: grpc.ServicerContext, - ) -> information_pb2.GetModuleCostResponse: + ) -> module_dto_pb2.GetModuleCostResponse: """Get information about the module's cost configuration. Args: @@ -886,14 +948,18 @@ async def GetModuleCost( logger.warning(e) context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details(str(e)) - return information_pb2.GetModuleCostResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.UNIMPLEMENTED), message=str(e)), success=False + ) + return module_dto_pb2.GetModuleCostResponse(result=result) except Exception as e: logger.exception("Failed to get cost format for module '%s'", self.module_class.__name__) context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Failed to get cost format: {e}") - return information_pb2.GetModuleCostResponse() + result = module_messages_pb2.ModuleResult( + error=OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False + ) + return module_dto_pb2.GetModuleCostResponse(result=result) - return information_pb2.GetModuleCostResponse( - success=True, - cost_schema=cost_format_struct, - ) + result = module_messages_pb2.ModuleResult(cost_schema=cost_format_struct, success=True) + return module_dto_pb2.GetModuleCostResponse(result=result) diff --git a/src/digitalkin/grpc_servers/utils/grpc_error_handler.py b/src/digitalkin/grpc_servers/utils/grpc_error_handler.py index 2fe1c76d..64871f38 100644 --- a/src/digitalkin/grpc_servers/utils/grpc_error_handler.py +++ b/src/digitalkin/grpc_servers/utils/grpc_error_handler.py @@ -12,7 +12,7 @@ class GrpcErrorHandlerMixin: """Mixin class providing common gRPC error handling functionality.""" @asynccontextmanager - async def handle_grpc_errors( # Mixin: self available for subclass overrides # noqa: PLR6301 + async def handle_grpc_errors( self, operation: str, service_error_class: type[Exception] | None = None, diff --git a/src/digitalkin/mixins/chat_history_mixin.py b/src/digitalkin/mixins/chat_history_mixin.py index fbc41a5f..d6923196 100644 --- a/src/digitalkin/mixins/chat_history_mixin.py +++ b/src/digitalkin/mixins/chat_history_mixin.py @@ -79,7 +79,7 @@ async def load_chat_history(self, context: ModuleContext) -> ChatHistory: if history_key in self._ch_cache: return self._ch_cache[history_key] - raw = await self.read_storage(context, self.CHAT_HISTORY_COLLECTION, history_key) + raw = await self.get_storage(context, self.CHAT_HISTORY_COLLECTION, history_key) if raw is not None: history = ChatHistory.model_validate(raw.data) self._ch_persisted.add(history_key) diff --git a/src/digitalkin/mixins/cost_mixin.py b/src/digitalkin/mixins/cost_mixin.py index 320f66a6..4c8bd777 100644 --- a/src/digitalkin/mixins/cost_mixin.py +++ b/src/digitalkin/mixins/cost_mixin.py @@ -4,7 +4,7 @@ from digitalkin.logger import logger from digitalkin.models.module.module_context import ModuleContext -from digitalkin.services.cost.cost_strategy import CostData +from digitalkin.models.services.cost import CostData class CostMixin: @@ -16,7 +16,7 @@ class CostMixin: """ @staticmethod - async def add_cost(context: ModuleContext, name: str, cost_config_name: str, quantity: float) -> None: + async def create_cost(context: ModuleContext, name: str, cost_config_name: str, quantity: float) -> None: """Add a cost entry using the cost strategy. Args: @@ -26,12 +26,12 @@ async def add_cost(context: ModuleContext, name: str, cost_config_name: str, qua quantity: Quantity of units consumed. """ try: - await context.cost.add(name, cost_config_name, quantity) + await context.cost.create(name, cost_config_name, quantity) except Exception: - logger.error("Failed to add cost '%s' (config=%s), continuing", name, cost_config_name, exc_info=True) + logger.error("Failed to create cost '%s' (config=%s), continuing", name, cost_config_name, exc_info=True) @staticmethod - async def get_cost(context: ModuleContext, name: str) -> list[CostData]: + async def list_cost(context: ModuleContext, name: str) -> list[CostData]: """Get cost entries for a specific name. Args: @@ -42,9 +42,9 @@ async def get_cost(context: ModuleContext, name: str) -> list[CostData]: List of cost data entries, empty on failure. """ try: - return await context.cost.get(name) + return await context.cost.list(name) except Exception: - logger.warning("Failed to get cost '%s', returning empty", name, exc_info=True) + logger.warning("Failed to list cost '%s', returning empty", name, exc_info=True) return [] @staticmethod @@ -74,7 +74,7 @@ async def get_costs( List of filtered cost data entries, empty on failure. """ try: - return await context.cost.get_filtered(names, cost_types) + return await context.cost.list(names, cost_types) except Exception: logger.warning("Failed to get filtered costs, returning empty", exc_info=True) return [] diff --git a/src/digitalkin/mixins/file_history_mixin.py b/src/digitalkin/mixins/file_history_mixin.py index fdd4f531..f1d1ae86 100644 --- a/src/digitalkin/mixins/file_history_mixin.py +++ b/src/digitalkin/mixins/file_history_mixin.py @@ -76,7 +76,7 @@ async def load_file_history(self, context: ModuleContext) -> FileHistory: if history_key in self._fh_cache: return self._fh_cache[history_key] - raw = await self.read_storage(context, self.FILE_HISTORY_COLLECTION, history_key) + raw = await self.get_storage(context, self.FILE_HISTORY_COLLECTION, history_key) if raw is not None: history = FileHistory.model_validate(raw.data) self._fh_persisted.add(history_key) diff --git a/src/digitalkin/mixins/filesystem_mixin.py b/src/digitalkin/mixins/filesystem_mixin.py index c57a0e96..1576ba9e 100644 --- a/src/digitalkin/mixins/filesystem_mixin.py +++ b/src/digitalkin/mixins/filesystem_mixin.py @@ -3,7 +3,7 @@ from typing import Any from digitalkin.models.module.module_context import ModuleContext -from digitalkin.services.filesystem.filesystem_strategy import FilesystemRecord +from digitalkin.models.services.filesystem import FilesystemRecord class FilesystemMixin: @@ -14,7 +14,7 @@ class FilesystemMixin: """ @staticmethod - async def upload_files(context: ModuleContext, files: list[Any]) -> tuple[list[FilesystemRecord], int, int]: + async def create_files(context: ModuleContext, files: list[Any]) -> tuple[list[FilesystemRecord], int, int]: """Upload files using the filesystem strategy. Args: @@ -27,10 +27,10 @@ async def upload_files(context: ModuleContext, files: list[Any]) -> tuple[list[F Raises: FilesystemServiceError: If upload operation fails """ - return await context.filesystem.upload_files(files) + return context.filesystem.create(files) @staticmethod - async def get_file(context: ModuleContext, file_id: str) -> FilesystemRecord: + async def list_file(context: ModuleContext, file_id: str) -> FilesystemRecord: """Retrieve a file by ID with the content. Args: @@ -43,4 +43,4 @@ async def get_file(context: ModuleContext, file_id: str) -> FilesystemRecord: Raises: FilesystemServiceError: If file retrieval fails """ - return await context.filesystem.get_file(file_id, include_content=True) + return context.filesystem.list(file_id, include_content=True) diff --git a/src/digitalkin/mixins/storage_mixin.py b/src/digitalkin/mixins/storage_mixin.py index 12a91012..d99db498 100644 --- a/src/digitalkin/mixins/storage_mixin.py +++ b/src/digitalkin/mixins/storage_mixin.py @@ -3,7 +3,7 @@ from typing import Any, Literal from digitalkin.models.module.module_context import ModuleContext -from digitalkin.services.storage.storage_strategy import StorageRecord +from digitalkin.models.services.storage import DataType, StorageRecord class StorageMixin: @@ -14,12 +14,12 @@ class StorageMixin: """ @staticmethod - async def store_storage( + async def create_storage( context: ModuleContext, collection: str, record_id: str | None, data: dict[str, Any], - data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT", + data_type: DataType = DataType.OUTPUT, ) -> StorageRecord: """Store data using the storage strategy. @@ -36,10 +36,10 @@ async def store_storage( Raises: StorageServiceError: If storage operation fails """ - return await context.storage.store(collection, record_id, data, data_type=data_type) + return await context.storage.create(collection, record_id, data, data_type=data_type) @staticmethod - async def read_storage(context: ModuleContext, collection: str, record_id: str) -> StorageRecord | None: + async def get_storage(context: ModuleContext, collection: str, record_id: str) -> StorageRecord | None: """Read data from storage. Args: @@ -53,7 +53,7 @@ async def read_storage(context: ModuleContext, collection: str, record_id: str) Raises: StorageServiceError: If read operation fails """ - return await context.storage.read(collection, record_id) + return await context.storage.get(collection, record_id) @staticmethod async def update_storage( diff --git a/src/digitalkin/models/base_enum.py b/src/digitalkin/models/base_enum.py new file mode 100644 index 00000000..3b8cc228 --- /dev/null +++ b/src/digitalkin/models/base_enum.py @@ -0,0 +1,67 @@ +"""Base enum with protobuf conversion support.""" + +from __future__ import annotations + +from typing import Generic, TypeVar, get_args, get_origin + +from typing_extensions import Self + +T = TypeVar("T", bound="BaseEnum") +P = TypeVar("P") # Type for proto enum + + +class BaseEnum(Generic[P]): + """Base enumeration mixin with protobuf conversion methods.""" + + @classmethod + def _get_proto_enum(cls) -> type[P] | None: + """Get the proto enum type from the generic parameter. + + Returns: + The proto enum type, or None if not found. + + Raises: + AttributeError: If proto enum type not found in generic parameters. + """ + for base in getattr(cls, "__orig_bases__", ()): + origin = get_origin(base) + if origin is BaseEnum or (origin is not None and issubclass(origin, BaseEnum)): + args = get_args(base) + if args: + return args[0] + msg = "Proto enum type not found in generic parameters." + raise AttributeError(msg) + + def to_proto(self) -> P | None: + """Convert this enum value to its protobuf equivalent. + + Returns: + The protobuf enum value, or None if conversion fails. + """ + try: + proto_enum = self.__class__._get_proto_enum() + if proto_enum is None: + return None + if "UNSPECIFIED" in self.value: + return proto_enum.Value(proto_enum.keys()[0]) # retourne 0 + return getattr(proto_enum, self.name) + except (AttributeError, IndexError): + return None + + @classmethod + def from_proto(cls, proto_value: P) -> Self: + """Crée une enum à partir d'une valeur d'enum protobuf. + + Args: + proto_value: La valeur de l'enum protobuf à convertir. + + Returns: + La valeur d'enum correspondante, ou UNSPECIFIED si conversion échoue ou si proto_value est l'élément 0. + """ + try: + proto_enum = cls._get_proto_enum() + if proto_value == next(iter(proto_enum.__dict__.values())): + return cls["UNSPECIFIED"] + return cls[proto_enum.Name(proto_value)] + except (KeyError, ValueError, AttributeError, IndexError): + return cls["UNSPECIFIED"] diff --git a/src/digitalkin/models/base_strategy.py b/src/digitalkin/models/base_strategy.py new file mode 100644 index 00000000..9ba6c74d --- /dev/null +++ b/src/digitalkin/models/base_strategy.py @@ -0,0 +1,107 @@ +"""This module contains the abstract base class for storage strategies.""" + +from abc import ABC, abstractmethod +from typing import Any + + +class BaseStrategy(ABC): + """Abstract base class for all strategies. + + This class defines the interface for all strategies. + """ + + def __init__(self, mission_id: str, setup_id: str, setup_version_id: str) -> None: + """Initialize the strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup this strategy is associated with + setup_version_id: The ID of the setup version this strategy is associated with + """ + self.mission_id: str = mission_id + self.setup_id: str = setup_id + self.setup_version_id: str = setup_version_id + + @abstractmethod + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Add a new resource. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "Create method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def get(self, *args: Any, **kwargs: Any) -> Any: + """Get one resources. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "Get method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def list(self, *args: Any, **kwargs: Any) -> Any: + """List one or more resources. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "List method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Search resources. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "Search method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Delete one or more resources. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "Delete method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Update a resource. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "Update method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Upload one or more resources. + + This method must be implemented by subclasses with their specific signature. + + Raises: + NotImplementedError: This function is not implemented yet. + """ + msg = "Upload method not implemented yet." + raise NotImplementedError(msg) diff --git a/src/digitalkin/models/grpc_servers/models.py b/src/digitalkin/models/grpc_servers/models.py index efb815dd..04ecae3e 100644 --- a/src/digitalkin/models/grpc_servers/models.py +++ b/src/digitalkin/models/grpc_servers/models.py @@ -226,7 +226,7 @@ def validate_port(cls, v: int) -> int: Raises: ConfigurationError: If port is outside valid range """ - if not 0 < v < 65536: # TCP port range constant # noqa: PLR2004 + if not 0 < v < 65536: # noqa: PLR2004 msg = f"Port must be between 1 and 65535, got {v}" raise ConfigurationError(msg) return v diff --git a/src/digitalkin/models/module/module_context.py b/src/digitalkin/models/module/module_context.py index b8bbeab7..f840a631 100644 --- a/src/digitalkin/models/module/module_context.py +++ b/src/digitalkin/models/module/module_context.py @@ -107,7 +107,7 @@ class ModuleContext: tool_cache: ToolCache request_metadata: RequestMetadata - def __init__( # All service strategies are mandatory constructor args # noqa: PLR0913, PLR0917 + def __init__( # noqa: PLR0913, PLR0917 self, agent: AgentStrategy, communication: CommunicationStrategy, diff --git a/src/digitalkin/models/module/setup_types.py b/src/digitalkin/models/module/setup_types.py index b31bc736..5782f170 100644 --- a/src/digitalkin/models/module/setup_types.py +++ b/src/digitalkin/models/module/setup_types.py @@ -393,7 +393,7 @@ async def _collect_from_tool_ref( infos = await tool_ref.resolve(registry, communication) for info in infos: self.resolved_tools[info.setup_id] = info - logger.info("Resolved tool '%s' -> module_id=%s", info.setup_id, info.module_id) + logger.info("Resolved tool '%s' -> module_id=%s", info.setup_id, info.id) except Exception: logger.exception("Failed to resolve ToolReference '%s'", field_name) diff --git a/src/digitalkin/models/module/tool_cache.py b/src/digitalkin/models/module/tool_cache.py index 7e5de704..5ebc63fb 100644 --- a/src/digitalkin/models/module/tool_cache.py +++ b/src/digitalkin/models/module/tool_cache.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from digitalkin.logger import logger -from digitalkin.models.services.registry import ModuleInfo +from digitalkin.models.services.modules import ModuleInfo class SelectedTool(BaseModel): @@ -109,7 +109,7 @@ def add(self, tool_module_info: ToolModuleInfo) -> None: "Tool cached", extra={ "setup_id": setup_id, - "module_id": tool_module_info.module_id, + "module_id": tool_module_info.id, }, ) @@ -177,12 +177,12 @@ async def module_info_to_tool_module_info( cost_config = schemas.get("cost", {}) return ToolModuleInfo( - module_id=module_info.module_id, - module_type=module_info.module_type, + id=module_info.id, + type=module_info.type, address=module_info.address, port=module_info.port, version=module_info.version, - module_name=module_info.module_name, + name=module_info.name, documentation=module_info.documentation, status=module_info.status, tools=tools, diff --git a/src/digitalkin/models/module/tool_reference.py b/src/digitalkin/models/module/tool_reference.py index 6f50809b..c71874ef 100644 --- a/src/digitalkin/models/module/tool_reference.py +++ b/src/digitalkin/models/module/tool_reference.py @@ -82,7 +82,7 @@ async def _resolve_single( setup = await registry.get_setup(entry.setup_id) if not setup or not setup.module_id: return None - info = await registry.discover_by_id(setup.module_id) + info = await registry.get(setup.module_id) if not info: return None tool_info = await module_info_to_tool_module_info(info, entry.setup_id, setup.name, communication) diff --git a/src/digitalkin/models/services/cost.py b/src/digitalkin/models/services/cost.py index c2760e46..868afa5a 100644 --- a/src/digitalkin/models/services/cost.py +++ b/src/digitalkin/models/services/cost.py @@ -4,66 +4,79 @@ from enum import Enum from typing import Annotated, Any, Literal +from agentic_mesh_protocol.cost.v1.cost_enums_pb2 import CostType as CostTypeProto from pydantic import BaseModel, Field +from digitalkin.models.base_enum import BaseEnum -class CostTypeEnum(Enum): - """Enumeration of supported cost types.""" - TOKEN_INPUT = "token_input" - TOKEN_OUTPUT = "token_output" - API_CALL = "api_call" - STORAGE = "storage" - TIME = "time" - CUSTOM = "custom" +class CostType(BaseEnum[CostTypeProto], Enum): + """Enum defining the types of costs that can be registered.""" + + OTHER = "OTHER" + TOKEN_INPUT = "TOKEN_INPUT" + TOKEN_OUTPUT = "TOKEN_OUTPUT" + API_CALL = "API_CALL" + STORAGE = "STORAGE" + TIME = "TIME" + CUSTOM = "CUSTOM" class CostConfig(BaseModel): """Pydantic model that defines a cost configuration. - :param cost_name: Name of the cost (unique identifier in the service). - :param cost_type: The type/category of the cost. + :param name: Name of the cost (unique identifier in the service). + :param type: The type/category of the cost. :param description: A short description of the cost. :param unit: The unit of measurement (e.g. token, call, MB). :param rate: The cost per unit (e.g. dollars per token). """ - name: str - type: CostTypeEnum - description: str | None = None - unit: str - rate: float + name: str = Field(description="Unique name for the cost configuration") + type: CostType = Field(description="The type/category of the cost") + description: str | None = Field(default=None, description="A short description of the cost") + unit: str = Field(description="The unit of measurement (e.g. token, call, MB)") + rate: float = Field(description="The cost per unit (e.g. dollars per token)") + + +class CostData(BaseModel): + """Data model for cost operations.""" + + cost: float = Field(description="The computed cost amount in dollars") + mission_id: str = Field(description="Identifier for the mission associated with the cost event") + name: str = Field(description="Identifier for the cost configuration") + type: CostType = Field(description="The type/category of the cost") + unit: str = Field(description="The unit of measurement (e.g. token, call, MB)") + rate: float = Field(description="The cost per unit (e.g. dollars per token)") + setup_version_id: str = Field(description="Identifier for the setup version associated with the cost event") + quantity: float = Field(description="The amount or units consumed (e.g. number of tokens, API calls)") class QuantityLimit(BaseModel): """Cost limit based on quantity (e.g., max 10000 tokens).""" - limit_type: Literal["quantity"] = "quantity" - name: str - type: CostTypeEnum - max_value: float + limit_type: Literal["quantity"] = Field(default="quantity", description="Discriminator for cost limit type") + name: str = Field(description="Identifier for the cost configuration") + type: CostType = Field(default=CostType.OTHER, description="The type/category of the cost") + max_value: float = Field(description="The maximum allowed quantity (e.g. number of tokens, API calls)") class AmountLimit(BaseModel): """Cost limit based on cost amount in dollars (e.g., max $1.00).""" - limit_type: Literal["amount"] = "amount" - name: str - type: CostTypeEnum - max_value: float - - -CostLimit = Annotated[QuantityLimit | AmountLimit, Field(discriminator="limit_type")] + limit_type: Literal["amount"] = Field(default="amount", description="Discriminator for cost limit type") + name: str = Field(description="Identifier for the cost configuration") + type: CostType = Field(description="The type/category of the cost") + max_value: float = Field(description="The maximum allowed cost amount in dollars") class CostEvent(BaseModel): """Pydantic model that represents a cost event registered during service execution. # DEPRECATED - :param cost_name: Identifier for the cost configuration. - :param cost_type: The type of cost. + :param name: Identifier for the cost configuration. :param usage: The amount or units consumed. - :param cost_amount: The computed cost amount; if not provided it is computed as usage*rate. + :param amount: The computed cost amount; if not provided it is computed as usage*rate. :param timestamp: The time when the cost event was recorded. :param metadata: Additional contextual information about the cost event. """ @@ -73,3 +86,6 @@ class CostEvent(BaseModel): amount: float timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) metadata: dict[str, Any] | None = None + + +CostLimit = Annotated[QuantityLimit | AmountLimit, Field(discriminator="limit_type")] diff --git a/src/digitalkin/models/services/filesystem.py b/src/digitalkin/models/services/filesystem.py new file mode 100644 index 00000000..8bb69843 --- /dev/null +++ b/src/digitalkin/models/services/filesystem.py @@ -0,0 +1,88 @@ +"""This module contains objects for filesystem strategies.""" + +from datetime import datetime +from enum import Enum +from typing import Any, Literal + +from agentic_mesh_protocol.filesystem.v1.filesystem_enums_pb2 import ( + FileStatus as FileStatusProto, +) +from agentic_mesh_protocol.filesystem.v1.filesystem_enums_pb2 import ( + FileType as FileTypeProto, +) +from pydantic import BaseModel, Field + +from digitalkin.models.base_enum import BaseEnum + + +class FileType(BaseEnum[FileTypeProto], Enum): + """Enumeration of file types.""" + + UNSPECIFIED = "UNSPECIFIED" + DOCUMENT = "DOCUMENT" + IMAGE = "IMAGE" + AUDIO = "AUDIO" + VIDEO = "VIDEO" + ARCHIVE = "ARCHIVE" + CODE = "CODE" + OTHER = "OTHER" + + +class FileStatus(BaseEnum[FileStatusProto], Enum): + """Enumeration of file statuses.""" + + UNSPECIFIED = "UNSPECIFIED" + UPLOADING = "UPLOADING" + ACTIVE = "ACTIVE" + PROCESSING = "PROCESSING" + ARCHIVED = "ARCHIVED" + DELETED = "DELETED" + + +class FilesystemRecord(BaseModel): + """Data model for filesystem operations.""" + + id: str = Field(description="Unique identifier for the file (UUID)") + context: str = Field(description="The context of the file in the filesystem") + name: str = Field(description="The name of the file") + type: FileType = Field(default=FileType.UNSPECIFIED, description="The type of data stored") + content_type: str = Field(default="application/octet-stream", description="The MIME type of the file") + size_bytes: int = Field(default=0, description="Size of the file in bytes") + checksum: str = Field(default="", description="SHA-256 checksum of the file content") + metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata for the file") + storage_uri: str = Field(description="Internal URI for accessing the file content") + url: str = Field(description="Public URL for accessing the file content") + status: FileStatus = Field(default=FileStatus.UNSPECIFIED, description="Current status of the file") + content: bytes | None = Field(default=None, description="The content of the file") + + +class FileFilter(BaseModel): + """Filter criteria for querying files.""" + + context: Literal["mission", "setup"] = Field( + default="mission", description="The context of the files (mission or setup)" + ) + names: list[str] | None = Field(default=None, description="Filter by file names (exact matches)") + ids: list[str] | None = Field(default=None, description="Filter by file IDs") + types: list[FileType] | None = Field(default=None, description="Filter by file types") + created_after: datetime | None = Field(default=None, description="Filter files created after this timestamp") + created_before: datetime | None = Field(default=None, description="Filter files created before this timestamp") + updated_after: datetime | None = Field(default=None, description="Filter files updated after this timestamp") + updated_before: datetime | None = Field(default=None, description="Filter files updated before this timestamp") + status: FileStatus | None = Field(default=None, description="Filter by file status") + content_type_prefix: str | None = Field(default=None, description="Filter by content type prefix (e.g., 'image/')") + min_size_bytes: int | None = Field(default=None, description="Filter files with minimum size") + max_size_bytes: int | None = Field(default=None, description="Filter files with maximum size") + prefix: str | None = Field(default=None, description="Filter by path prefix (e.g., 'folder1/')") + content_type: str | None = Field(default=None, description="Filter by content type") + + +class UploadFileData(BaseModel): + """Data model for uploading a file.""" + + content: bytes = Field(description="The content of the file") + name: str = Field(description="The name of the file") + type: FileType = Field(description="The type of the file") + content_type: str | None = Field(default=None, description="The content type of the file") + metadata: dict[str, Any] | None = Field(default=None, description="The metadata of the file") + replace_if_exists: bool = Field(default=False, description="Whether to replace the file if it already exists") diff --git a/src/digitalkin/models/services/modules.py b/src/digitalkin/models/services/modules.py new file mode 100644 index 00000000..3923ec75 --- /dev/null +++ b/src/digitalkin/models/services/modules.py @@ -0,0 +1,42 @@ +"""Registry data models. + +This module contains Pydantic models for registry service data structures. +""" + +from enum import Enum + +from agentic_mesh_protocol.module.v1.module_enums_pb2 import ModuleStatus as ModuleStatusProto +from agentic_mesh_protocol.module.v1.module_enums_pb2 import ModuleType as ModuleTypeProto +from pydantic import BaseModel, Field + +from digitalkin.models.base_enum import BaseEnum + + +class ModuleStatus(BaseEnum[ModuleStatusProto], Enum): + """Module status in the registry.""" + + UNSPECIFIED = "UNSPECIFIED" + READY = "READY" + ACTIVE = "ACTIVE" + ARCHIVED = "ARCHIVED" + + +class ModuleType(BaseEnum[ModuleTypeProto], Enum): + """Module type in the registry.""" + + UNSPECIFIED = "UNSPECIFIED" + ARCHETYPE = "ARCHETYPE" + TOOL = "TOOL" + + +class ModuleInfo(BaseModel): + """Module information from registry.""" + + id: str = Field(description="Unique identifier for the module.") + type: ModuleType = Field(default=ModuleType.UNSPECIFIED, description="Type of the module.") + address: str = Field(default="", description="Address of the module.") + port: int = Field(default=0, description="Port number of the module.") + version: str = Field(default="", description="Version of the module.") + name: str = Field(default="", description="Name of the module.") + documentation: str | None = Field(default=None, description="Documentation for the module.") + status: ModuleStatus | None = Field(default=None, description="Current status of the module.") diff --git a/src/digitalkin/models/services/registry.py b/src/digitalkin/models/services/registry.py deleted file mode 100644 index d43c4311..00000000 --- a/src/digitalkin/models/services/registry.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Registry data models.""" - -from enum import Enum -from typing import Any - -from pydantic import BaseModel - - -class RegistryModuleStatus(str, Enum): - """Module status in the registry.""" - - UNSPECIFIED = "unspecified" - READY = "ready" - ACTIVE = "active" - ARCHIVED = "archived" - - -class RegistryModuleType(str, Enum): - """Module type in the registry.""" - - UNSPECIFIED = "unspecified" - ARCHETYPE = "archetype" - TOOL = "tool" - - -class ModuleInfo(BaseModel): - """Module information from registry.""" - - module_id: str = "" - module_type: RegistryModuleType = RegistryModuleType.UNSPECIFIED - address: str = "" - port: int = 0 - version: str = "" - module_name: str = "" - documentation: str | None = None - status: RegistryModuleStatus | None = None - - -class RegistrySetupStatus(str, Enum): - """Setup status in the registry.""" - - UNSPECIFIED = "unspecified" - DRAFT = "draft" - WAITING_FOR_APPROVAL = "waiting_for_approval" - READY = "ready" - PAUSED = "paused" - FAILED = "failed" - ARCHIVED = "archived" - NEEDS_CONFIGURATION = "needs_configuration" - CONFIGURATION_FAILED = "configuration_failed" - CONFIGURATION_SUCCEEDED = "configuration_succeeded" - - -class RegistryVisibility(str, Enum): - """Visibility in the registry.""" - - UNSPECIFIED = "unspecified" - PUBLIC = "public" - PRIVATE = "private" - INTERNAL = "internal" - - -class SetupInfo(BaseModel): - """Setup information from registry.""" - - setup_id: str - name: str - documentation: str | None = None - status: RegistrySetupStatus | None = None - visibility: RegistryVisibility | None = None - organization_id: str | None = None - owner_id: str | None = None - card_id: str | None = None - module_id: str | None = None - setup_version_id: str | None = None - setup_version: str | None = None - config: dict[str, Any] | None = None diff --git a/src/digitalkin/models/services/setup.py b/src/digitalkin/models/services/setup.py new file mode 100644 index 00000000..fed1fa19 --- /dev/null +++ b/src/digitalkin/models/services/setup.py @@ -0,0 +1,75 @@ +"""Setup-related models and enums.""" + +import datetime +from enum import Enum +from typing import Any + +from agentic_mesh_protocol.registry.v1.registry_enums_pb2 import Visibility as VisibilityProto +from agentic_mesh_protocol.setup.v1.setup_enums_pb2 import SetupStatus as SetupStatusProto +from pydantic import BaseModel, Field + +from digitalkin.models.base_enum import BaseEnum + + +class Visibility(BaseEnum[VisibilityProto], Enum): + """Visibility in the registry.""" + + UNSPECIFIED = "UNSPECIFIED" + PUBLIC = "PUBLIC" + PRIVATE = "PRIVATE" + INTERNAL = "INTERNAL" + + +class SetupStatus(BaseEnum[SetupStatusProto], Enum): + """Setup status in the registry.""" + + UNSPECIFIED = "UNSPECIFIED" + DRAFT = "DRAFT" + WAITING_FOR_APPROVAL = "WAITING_FOR_APPROVAL" + READY = "READY" + PAUSED = "PAUSED" + FAILED = "FAILED" + ARCHIVED = "ARCHIVED" + NEEDS_CONFIGURATION = "NEEDS_CONFIGURATION" + CONFIGURATION_FAILED = "CONFIGURATION_FAILED" + CONFIGURATION_SUCCEEDED = "CONFIGURATION_SUCCEEDED" + + +class SetupInfo(BaseModel): + """Setup information from registry.""" + + setup_id: str = Field(description="Unique identifier for the setup.") + name: str = Field(description="Name of the setup.") + documentation: str | None = Field(default=None, description="Documentation for the setup.") + status: SetupStatus | None = Field(default=None, description="Current status of the setup.") + visibility: Visibility | None = Field(default=None, description="Visibility level of the setup.") + organization_id: str | None = Field( + default=None, description="Identifier for the organization that owns the setup." + ) + owner_id: str | None = Field(default=None, description="Identifier for the owner of the setup.") + card_id: str | None = Field(default=None, description="Identifier for the card associated with the setup.") + module_id: str | None = Field(default=None, description="Identifier for the module associated with the setup.") + setup_version_id: str | None = Field(default=None, description="Identifier for the setup version.") + setup_version: str | None = Field(default=None, description="Version of the setup.") + config: dict[str, Any] | None = Field(default=None, description="Configuration for the setup.") + + +class SetupVersionData(BaseModel): + """Pydantic model for SetupVersion data validation.""" + + id: str = Field(description="Unique identifier for the setup version.") + setup_id: str = Field(description="Identifier for the setup associated with this version.") + version: str = Field(description="Version string for the setup version.") + content: dict[str, Any] = Field(description="Content/configuration for the setup version.") + created_at: datetime.datetime = Field(description="Timestamp when the setup version was created.") + + +class SetupData(BaseModel): + """Pydantic model for Setup data validation.""" + + id: str = Field(description="Unique identifier for the setup.") + name: str = Field(description="Name of the setup.") + organization_id: str = Field(description="Identifier for the organization that owns the setup.") + owner_id: str = Field(description="Identifier for the owner of the setup.") + module_id: str = Field(description="Identifier for the module associated with the setup.") + current_setup_version: SetupVersionData = Field(description="Current version of the setup.") diff --git a/src/digitalkin/models/services/storage.py b/src/digitalkin/models/services/storage.py index 615eb740..f2dd2774 100644 --- a/src/digitalkin/models/services/storage.py +++ b/src/digitalkin/models/services/storage.py @@ -1,10 +1,14 @@ """Storage model.""" +import datetime from enum import Enum from typing import Any +from agentic_mesh_protocol.storage.v1.storage_enums_pb2 import DataType as DataTypeProto from pydantic import BaseModel, Field +from digitalkin.models.base_enum import BaseEnum + class BaseRole(str, Enum): """Officially supported Role Enum for chat messages.""" @@ -42,3 +46,25 @@ class FileHistory(BaseModel): """File history model.""" files: list[FileModel] = Field(..., description="List of files") + + +class DataType(BaseEnum[DataTypeProto], Enum): + """Enum defining the types of data that can be stored.""" + + UNSPECIFIED = "UNSPECIFIED" + OUTPUT = "OUTPUT" + VIEW = "VIEW" + LOGS = "LOGS" + OTHER = "OTHER" + + +class StorageRecord(BaseModel): + """A single record stored in a collection, with metadata.""" + + mission_id: str = Field(..., description="ID of the mission (bucket) this doc belongs to") + collection: str = Field(..., description="Logical collection name") + record_id: str = Field(..., description="Unique ID of this record in its collection") + data_type: DataType = Field(default=DataType.OUTPUT, description="Category of the data of this record") + data: BaseModel = Field(..., description="The typed payload of this record") + created_at: datetime.datetime | None = Field(default=None, description="When this record was first created") + updated_at: datetime.datetime | None = Field(default=None, description="When this record was last modified") diff --git a/src/digitalkin/modules/_base_module.py b/src/digitalkin/modules/_base_module.py index 98fbd92f..54e8f82c 100644 --- a/src/digitalkin/modules/_base_module.py +++ b/src/digitalkin/modules/_base_module.py @@ -27,7 +27,7 @@ from digitalkin.utils.schema_splitter import SchemaSplitter -class BaseModule( # Module SDK base class requires many public methods # noqa: PLR0904 +class BaseModule( # noqa: PLR0904 ABC, Generic[ InputModelT, @@ -322,8 +322,8 @@ async def get_cost_format(cls, *, llm_format: bool) -> str: # Convert CostConfig objects to serializable dict cost_schema = { name: { - "name": cost_config.cost_name, - "type": cost_config.cost_type, + "name": cost_config.name, + "type": cost_config.type.to_proto(), "description": cost_config.description, "unit": cost_config.unit, "rate": cost_config.rate, @@ -491,9 +491,9 @@ async def cleanup(self) -> None: """Run the module.""" ... - async def run_config_setup( # Default implementation; subclasses may use self # noqa: PLR6301 + async def run_config_setup( self, - context: ModuleContext, # Available for subclass overrides # noqa: ARG002 + _context: ModuleContext, # Available for subclass overrides config_setup_data: SetupModelT, ) -> SetupModelT: """Run config setup the module. diff --git a/src/digitalkin/modules/trigger_handler.py b/src/digitalkin/modules/trigger_handler.py index 32e3a9a1..08ac46f3 100644 --- a/src/digitalkin/modules/trigger_handler.py +++ b/src/digitalkin/modules/trigger_handler.py @@ -21,7 +21,7 @@ class TriggerHandler(ABC, BaseMixin, Generic[InputModelT, SetupModelT, OutputMod input_format: type[InputModelT] output_format: type[OutputModelT] - def __init__(self, context: ModuleContext) -> None: # noqa: ARG002 + def __init__(self, _context: ModuleContext) -> None: # context available for subclass overrides """Initialize the TriggerHandler with the given context.""" super().__init__() diff --git a/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py b/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py index e18d943b..37fe9952 100644 --- a/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py +++ b/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py @@ -30,15 +30,15 @@ def __init__(self, context: ModuleContext) -> None: async def handle( self, - input_data: HealthcheckPingInput, # Healthcheck needs no input data # noqa: ARG002 - setup_data: Any, # Module-agnostic setup; healthcheck ignores it # noqa: ARG002 + _input_data: HealthcheckPingInput, # Healthcheck needs no input data + _setup_data: Any, # Module-agnostic setup; healthcheck ignores it context: ModuleContext, ) -> None: """Handle ping healthcheck request. Args: - input_data: The input trigger data (unused for healthcheck). - setup_data: The setup configuration (unused for healthcheck). + _input_data: The input trigger data (unused for healthcheck). + _setup_data: The setup configuration (unused for healthcheck). context: The module context. """ elapsed = datetime.now(tz=context.session.timezone) - self._request_time diff --git a/src/digitalkin/modules/triggers/healthcheck_services_trigger.py b/src/digitalkin/modules/triggers/healthcheck_services_trigger.py index c73c4430..53fd0935 100644 --- a/src/digitalkin/modules/triggers/healthcheck_services_trigger.py +++ b/src/digitalkin/modules/triggers/healthcheck_services_trigger.py @@ -28,15 +28,15 @@ def __init__(self, context: ModuleContext) -> None: async def handle( self, - input_data: HealthcheckServicesInput, # Healthcheck needs no input data # noqa: ARG002 - setup_data: Any, # Module-agnostic setup; healthcheck ignores it # noqa: ARG002 + _input_data: HealthcheckServicesInput, # Healthcheck needs no input data + _setup_data: Any, # Module-agnostic setup; healthcheck ignores it context: ModuleContext, ) -> None: """Handle services healthcheck request. Args: - input_data: The input trigger data (unused for healthcheck). - setup_data: The setup configuration (unused for healthcheck). + _input_data: The input trigger data (unused for healthcheck). + _setup_data: The setup configuration (unused for healthcheck). context: The module context. """ services = { diff --git a/src/digitalkin/modules/triggers/healthcheck_status_trigger.py b/src/digitalkin/modules/triggers/healthcheck_status_trigger.py index 053b18ae..f2c42ed7 100644 --- a/src/digitalkin/modules/triggers/healthcheck_status_trigger.py +++ b/src/digitalkin/modules/triggers/healthcheck_status_trigger.py @@ -29,15 +29,15 @@ def __init__(self, context: ModuleContext) -> None: async def handle( self, - input_data: HealthcheckStatusInput, # Healthcheck needs no input data # noqa: ARG002 - setup_data: Any, # Module-agnostic setup; healthcheck ignores it # noqa: ARG002 + _input_data: HealthcheckStatusInput, # Healthcheck needs no input data + _setup_data: Any, # Module-agnostic setup; healthcheck ignores it context: ModuleContext, ) -> None: """Handle status healthcheck request. Args: - input_data: The input trigger data (unused for healthcheck). - setup_data: The setup configuration (unused for healthcheck). + _input_data: The input trigger data (unused for healthcheck). + _setup_data: The setup configuration (unused for healthcheck). context: The module context. """ output = HealthcheckStatusOutput( diff --git a/src/digitalkin/services/agent/__init__.py b/src/digitalkin/services/agent/__init__.py index 5f1d2d14..c895e7a4 100644 --- a/src/digitalkin/services/agent/__init__.py +++ b/src/digitalkin/services/agent/__init__.py @@ -1,6 +1,6 @@ """This module is responsible for handling the agent services.""" +from digitalkin.services.agent.agent_default import DefaultAgent from digitalkin.services.agent.agent_strategy import AgentStrategy -from digitalkin.services.agent.default_agent import DefaultAgent __all__ = ["AgentStrategy", "DefaultAgent"] diff --git a/src/digitalkin/services/agent/default_agent.py b/src/digitalkin/services/agent/agent_default.py similarity index 80% rename from src/digitalkin/services/agent/default_agent.py rename to src/digitalkin/services/agent/agent_default.py index 46b69bb5..1651510e 100644 --- a/src/digitalkin/services/agent/default_agent.py +++ b/src/digitalkin/services/agent/agent_default.py @@ -6,8 +6,8 @@ class DefaultAgent(AgentStrategy): """Default agent implementation for the agent service.""" - def start(self) -> None: + async def start(self) -> None: """Start the agent.""" - def stop(self) -> None: + async def stop(self) -> None: """Stop the agent.""" diff --git a/src/digitalkin/services/agent/agent_strategy.py b/src/digitalkin/services/agent/agent_strategy.py index 6cdd7cdb..71329a77 100644 --- a/src/digitalkin/services/agent/agent_strategy.py +++ b/src/digitalkin/services/agent/agent_strategy.py @@ -1,19 +1,80 @@ """This module contains the abstract base class for agent strategies.""" from abc import ABC, abstractmethod +from typing import Any -from digitalkin.services.base_strategy import BaseStrategy +from digitalkin.models.base_strategy import BaseStrategy class AgentStrategy(BaseStrategy, ABC): """Abstract base class for agent strategies.""" + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + @abstractmethod - def start(self) -> None: + async def start(self) -> None: """Start the agent.""" ... @abstractmethod - def stop(self) -> None: + async def stop(self) -> None: """Stop the agent.""" - ... + raise NotImplementedError + + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # + + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().create(args, kwargs) + + async def get(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().get(args, kwargs) + + async def list(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().list(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().search(args, kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().delete(args, kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().update(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/base_strategy.py b/src/digitalkin/services/base_strategy.py deleted file mode 100644 index 18925001..00000000 --- a/src/digitalkin/services/base_strategy.py +++ /dev/null @@ -1,25 +0,0 @@ -"""This module contains the abstract base class for storage strategies.""" - -from abc import ABC - - -class BaseStrategy(ABC): - """Abstract base class for all strategies. - - This class defines the interface for all strategies. - """ - - def __init__(self, mission_id: str, setup_id: str, setup_version_id: str) -> None: - """Initialize the strategy. - - Args: - mission_id: The ID of the mission this strategy is associated with - setup_id: The ID of the setup this strategy is associated with - setup_version_id: The ID of the setup version this strategy is associated with - """ - self.mission_id: str = mission_id - self.setup_id: str = setup_id - self.setup_version_id: str = setup_version_id - - async def close(self) -> None: - """Release resources held by this strategy. No-op by default.""" diff --git a/src/digitalkin/services/communication/__init__.py b/src/digitalkin/services/communication/__init__.py index 51878514..2956d823 100644 --- a/src/digitalkin/services/communication/__init__.py +++ b/src/digitalkin/services/communication/__init__.py @@ -1,7 +1,7 @@ """Communication service for module-to-module interaction.""" +from digitalkin.services.communication.communication_default import DefaultCommunication +from digitalkin.services.communication.communication_grpc import GrpcCommunication from digitalkin.services.communication.communication_strategy import CommunicationStrategy -from digitalkin.services.communication.default_communication import DefaultCommunication -from digitalkin.services.communication.grpc_communication import GrpcCommunication __all__ = ["CommunicationStrategy", "DefaultCommunication", "GrpcCommunication"] diff --git a/src/digitalkin/services/communication/default_communication.py b/src/digitalkin/services/communication/communication_default.py similarity index 71% rename from src/digitalkin/services/communication/default_communication.py rename to src/digitalkin/services/communication/communication_default.py index ebc46693..29b3f005 100644 --- a/src/digitalkin/services/communication/default_communication.py +++ b/src/digitalkin/services/communication/communication_default.py @@ -19,33 +19,20 @@ def __init__( setup_id: str, setup_version_id: str, ) -> None: - """Initialize the default communication service. - - Args: - mission_id: Mission identifier - setup_id: Setup identifier - setup_version_id: Setup version identifier - """ + """Initialize with local communication (no remote connections).""" super().__init__(mission_id, setup_id, setup_version_id) logger.debug("Initialized DefaultCommunication (local)") - async def get_module_schemas( # Default stub implementation; self available for subclass overrides # noqa: PLR6301 + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def get_module_schemas( self, module_address: str, module_port: int, *, llm_format: bool = False, ) -> dict[str, dict]: - """Get module schemas (local implementation returns empty schemas). - - Args: - module_address: Target module address - module_port: Target module port - llm_format: Return LLM-friendly format - - Returns: - Empty schemas dictionary - """ + """Return empty schemas (local stub).""" logger.debug( "DefaultCommunication.get_module_schemas called (returns empty)", extra={ @@ -61,26 +48,26 @@ async def get_module_schemas( # Default stub implementation; self available for "secret": {}, } - async def call_module( # Default stub implementation; self available for subclass overrides # noqa: PLR6301 + async def call_module( self, module_address: str, module_port: int, - input_data: dict, # Strategy interface parameter, not used in local stub # noqa: ARG002 + _input_data: dict, # Strategy interface parameter, not used in local stub setup_id: str, mission_id: str, callback: Callable[[dict], Awaitable[None]] | None = None, - metadata: dict[str, str] | None = None, # noqa: ARG002 + _metadata: dict[str, str] | None = None, ) -> AsyncGenerator[dict, None]: """Call module (local implementation yields empty response). Args: module_address: Target module address module_port: Target module port - input_data: Input data + _input_data: Input data (unused in local stub) setup_id: Setup ID mission_id: Mission ID callback: Optional callback - metadata: Optional gRPC metadata (headers). + _metadata: Optional gRPC metadata (unused in local stub). Yields: Empty response dictionary diff --git a/src/digitalkin/services/communication/grpc_communication.py b/src/digitalkin/services/communication/communication_grpc.py similarity index 80% rename from src/digitalkin/services/communication/grpc_communication.py rename to src/digitalkin/services/communication/communication_grpc.py index 7b558f06..0fe88759 100644 --- a/src/digitalkin/services/communication/grpc_communication.py +++ b/src/digitalkin/services/communication/communication_grpc.py @@ -5,16 +5,15 @@ import grpc.aio from agentic_mesh_protocol.module.v1 import ( - information_pb2, - lifecycle_pb2, + module_dto_pb2, module_service_pb2_grpc, ) from google.protobuf import json_format, struct_pb2 from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.logger import logger +from digitalkin.models.base_strategy import BaseStrategy from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.services.base_strategy import BaseStrategy from digitalkin.services.communication.communication_strategy import CommunicationStrategy @@ -34,14 +33,7 @@ def __init__( setup_version_id: str, client_config: ClientConfig, ) -> None: - """Initialize the gRPC communication client. - - Args: - mission_id: Mission identifier - setup_id: Setup identifier - setup_version_id: Setup version identifier - client_config: Client configuration for gRPC connection - """ + """Initialize with gRPC client configuration for remote module communication.""" BaseStrategy.__init__(self, mission_id, setup_id, setup_version_id) self.client_config = client_config # Track cache keys this instance owns refs on, for cleanup @@ -52,7 +44,38 @@ def __init__( extra={"security": client_config.security}, ) - def _get_or_create_channel(self, module_address: str, module_port: int) -> grpc.aio.Channel: + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # + + def __create_stub(self, module_address: str, module_port: int) -> module_service_pb2_grpc.ModuleServiceStub: + """Create a new stub for the target module. + + Args: + module_address: Module host address + module_port: Module port + + Returns: + ModuleServiceStub for the target module + """ + logger.debug( + "Creating connection", + extra={"address": module_address, "port": module_port}, + ) + + config = ClientConfig( + host=module_address, + port=module_port, + mode=self.client_config.mode, + security=self.client_config.security, + credentials=self.client_config.credentials, + channel_options=self.client_config.channel_options, + ) + + channel = self._init_channel(config) + return module_service_pb2_grpc.ModuleServiceStub(channel) + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + def get_or_create_channel(self, module_address: str, module_port: int) -> grpc.aio.Channel: """Get or create a shared cached channel for the target module. Uses GrpcClientWrapper._channel_cache for ref-counted sharing so @@ -89,19 +112,6 @@ async def close(self) -> None: """Release all pooled gRPC channels.""" await self.close_all_channels() - def _create_stub(self, module_address: str, module_port: int) -> module_service_pb2_grpc.ModuleServiceStub: - """Create a new stub for the target module. - - Args: - module_address: Module host address - module_port: Module port - - Returns: - ModuleServiceStub for the target module - """ - channel = self._get_or_create_channel(module_address, module_port) - return module_service_pb2_grpc.ModuleServiceStub(channel) - async def get_module_schemas( self, module_address: str, @@ -119,16 +129,14 @@ async def get_module_schemas( Returns: Dictionary containing schemas: input, output, setup, secret, cost """ - stub = self._create_stub(module_address, module_port) + stub = self.__create_stub(module_address, module_port) # Create requests - # Note: cost always uses llm_format=False to get actual config data (rates, units) - # No LLM are allowed to set costs - input_request = information_pb2.GetModuleInputRequest(llm_format=llm_format) - output_request = information_pb2.GetModuleOutputRequest(llm_format=llm_format) - setup_request = information_pb2.GetModuleSetupRequest(llm_format=llm_format) - secret_request = information_pb2.GetModuleSecretRequest(llm_format=llm_format) - cost_request = information_pb2.GetModuleCostRequest(llm_format=False) + input_request = module_dto_pb2.GetModuleInputRequest(llm_format=llm_format) + output_request = module_dto_pb2.GetModuleOutputRequest(llm_format=llm_format) + setup_request = module_dto_pb2.GetModuleSetupRequest(llm_format=llm_format) + secret_request = module_dto_pb2.GetModuleSecretRequest(llm_format=llm_format) + cost_request = module_dto_pb2.GetModuleCostRequest(llm_format=llm_format) # Get all schemas in parallel input_response, output_response, setup_response, secret_response, cost_response = await asyncio.gather( @@ -180,14 +188,14 @@ async def call_module( Yields: Streaming responses from module as dictionaries """ - stub = self._create_stub(module_address, module_port) + stub = self.__create_stub(module_address, module_port) # Convert input data to protobuf Struct input_struct = struct_pb2.Struct() input_struct.update(input_data) # Create request - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( input=input_struct, setup_id=setup_id, mission_id=mission_id, diff --git a/src/digitalkin/services/communication/communication_strategy.py b/src/digitalkin/services/communication/communication_strategy.py index 10d8afd1..34783859 100644 --- a/src/digitalkin/services/communication/communication_strategy.py +++ b/src/digitalkin/services/communication/communication_strategy.py @@ -2,22 +2,40 @@ from abc import ABC, abstractmethod from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any -from digitalkin.services.base_strategy import BaseStrategy +from digitalkin.models.base_strategy import BaseStrategy class CommunicationStrategy(BaseStrategy, ABC): """Abstract base class for module-to-module communication. This service enables: - - Archetype → Tool communication - - Archetype → Archetype communication - - Tool → Tool communication - - Any module → Any module communication + - Archetype -> Tool communication + - Archetype -> Archetype communication + - Tool -> Tool communication + - Any module -> Any module communication The service wraps the Module Service protocol from agentic-mesh-protocol. """ + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + ) -> None: + """Initialize the default communication service. + + Args: + mission_id: Mission identifier + setup_id: Setup identifier + setup_version_id: Setup version identifier + """ + super().__init__(mission_id, setup_id, setup_version_id) + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + @abstractmethod async def close(self) -> None: """Release communication resources (channels, connection pools).""" @@ -82,3 +100,62 @@ async def call_module( # Make this an actual async generator to satisfy type checkers if False: # pragma: no cover yield {} + raise NotImplementedError + + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # + + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().create(args, kwargs) + + async def get(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().get(args, kwargs) + + async def list(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().list(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().search(args, kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().delete(args, kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().update(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/cost/__init__.py b/src/digitalkin/services/cost/__init__.py index c602b47f..8854daed 100644 --- a/src/digitalkin/services/cost/__init__.py +++ b/src/digitalkin/services/cost/__init__.py @@ -1,14 +1,11 @@ """This module is responsible for handling the cost services.""" -from digitalkin.services.cost.cost_strategy import CostConfig, CostData, CostStrategy, CostType -from digitalkin.services.cost.default_cost import DefaultCost -from digitalkin.services.cost.grpc_cost import GrpcCost +from digitalkin.services.cost.cost_default import DefaultCost +from digitalkin.services.cost.cost_grpc import GrpcCost +from digitalkin.services.cost.cost_strategy import CostStrategy __all__ = [ - "CostConfig", - "CostData", "CostStrategy", - "CostType", "DefaultCost", "GrpcCost", ] diff --git a/src/digitalkin/services/cost/default_cost.py b/src/digitalkin/services/cost/cost_default.py similarity index 59% rename from src/digitalkin/services/cost/default_cost.py rename to src/digitalkin/services/cost/cost_default.py index 86144dba..6010f07a 100644 --- a/src/digitalkin/services/cost/default_cost.py +++ b/src/digitalkin/services/cost/cost_default.py @@ -1,15 +1,10 @@ """Default cost.""" -from typing import Literal - +from digitalkin.exception.cost import CostServiceError from digitalkin.logger import logger -from digitalkin.models.services.cost import AmountLimit, QuantityLimit +from digitalkin.models.services.cost import AmountLimit, CostConfig, CostData, CostType, QuantityLimit from digitalkin.services.cost.cost_strategy import ( - CostConfig, - CostData, - CostServiceError, CostStrategy, - CostType, ) @@ -25,62 +20,23 @@ def __init__(self, mission_id: str, setup_id: str, setup_version_id: str, config setup_version_id: The ID of the setup version this strategy is associated with config: The configuration dictionary for the cost """ - super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id) - self.config = config + super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=config) self.db: dict[str, list[CostData]] = {} self._limits: dict[str, QuantityLimit | AmountLimit] = {} self._accumulated: dict[str, float] = {} - async def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None: - """Set cost limits for this session. - - Args: - limits: List of CostLimit objects to enforce. - """ - self._limits = {limit.name: limit for limit in limits} - self._accumulated = {} - - async def check_limit(self, cost_config_name: str, quantity: float) -> bool: - """Check if adding this cost would exceed any limits. - - Args: - cost_config_name: Name of the cost config. - quantity: Quantity to add. + # ══════════════════════════════════ Publics Methods ═══════════════════════════════════ # - Returns: - True if within limits, False if would exceed. - """ - limit = self._limits.get(cost_config_name) - if limit is None: - return True - - cost_config = self.config.get(cost_config_name) - if cost_config is None: - return True - - if limit.limit_type == "quantity": - current = self._accumulated.get(f"{cost_config_name}_quantity", 0) - return current + quantity <= limit.max_value - - current = self._accumulated.get(f"{cost_config_name}_amount", 0) - projected = cost_config.rate * quantity - return current + projected <= limit.max_value - - async def add( + async def create( self, name: str, cost_config_name: str, quantity: float, ) -> None: - """Create a new record in the cost database. - - Args: - name: The name of the cost - cost_config_name: The name of the cost config - quantity: The quantity of the cost + """Create a cost record in local storage. Raises: - CostServiceError: If the cost data is invalid or if the cost already exists + CostServiceError: If cost config not found or duplicate name. """ cost_config = self.config.get(cost_config_name) if cost_config is None: @@ -91,7 +47,7 @@ async def add( "name": name, "cost": cost_config.rate * quantity, "unit": cost_config.unit, - "cost_type": CostType[cost_config.cost_type], + "type": cost_config.type, "mission_id": self.mission_id, "rate": cost_config.rate, "quantity": quantity, @@ -105,41 +61,58 @@ async def add( raise CostServiceError(msg) self.db[cost_data.mission_id].append(cost_data) - async def get(self, name: str) -> list[CostData]: - """Get a record from the database. - - Args: - name: The name of the cost + async def set_config(self, configs: list[CostConfig]) -> bool: + """Store cost configs in memory. Returns: - list[CostData]: The cost data + True when configs are stored. + """ + self.config = {config.cost_name: config for config in configs} + logger.debug("Cost configs stored in memory: %s", self.config) + return True - Raises: - CostServiceError: If the cost data is invalid or if the cost does not exist + async def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None: + """Store cost limits in memory.""" + self._limits = {limit.name: limit for limit in limits} + self._accumulated = {} + + async def check_limit(self, cost_config_name: str, quantity: float) -> bool: + """Check if adding quantity would exceed configured limits. + + Returns: + True if within limits, False if would exceed. """ - if self.mission_id not in self.db: - msg = f"Mission {self.mission_id} not found in the database." - logger.warning(msg) - raise CostServiceError(msg) + limit = self._limits.get(cost_config_name) + if limit is None: + return True + + cost_config = self.config.get(cost_config_name) + if cost_config is None: + return True + + if limit.limit_type == "quantity": + current = self._accumulated.get(f"{cost_config_name}_quantity", 0) + return current + quantity <= limit.max_value + + current = self._accumulated.get(f"{cost_config_name}_amount", 0) + projected = cost_config.rate * quantity + return current + projected <= limit.max_value - return [cost for cost in self.db[self.mission_id] if cost.name == name] or [] + async def list_config(self) -> list[CostConfig]: + """Not implemented.""" - async def get_filtered( + async def list( self, names: list[str] | None = None, - cost_types: list[Literal["TOKEN_INPUT", "TOKEN_OUTPUT", "API_CALL", "STORAGE", "TIME", "OTHER"]] | None = None, + cost_types: list[CostType] | None = None, ) -> list[CostData]: - """Get records from the database. - - Args: - names: The names of the costs - cost_types: The types of the costs + """List cost records filtered by names or types. Returns: - list[CostData]: The list of records + Filtered list of cost data. Raises: - CostServiceError: If the cost data is invalid or if the cost does not exist + CostServiceError: If mission not found. """ if self.mission_id not in self.db: msg = f"Mission {self.mission_id} not found in the database." @@ -149,26 +122,5 @@ async def get_filtered( return [ cost for cost in self.db[self.mission_id] - if (names and cost.name in names) or (cost_types and cost.cost_type in cost_types) + if (names and cost.name in names) or (cost_types and cost.type in cost_types) ] - - async def get_cost_config(self) -> list[CostConfig]: - """Get cost configuration from in-memory config. - - Returns: - List of CostConfig objects from the config dictionary. - """ - return list(self.config.values()) - - async def set_cost_config(self, configs: list[CostConfig]) -> bool: - """Store cost configuration in memory. - - Args: - configs: List of CostConfig objects to store. - - Returns: - True if successfully stored. - """ - self.config = {config.cost_name: config for config in configs} - logger.debug("Cost configs stored in memory: %s", self.config) - return True diff --git a/src/digitalkin/services/cost/grpc_cost.py b/src/digitalkin/services/cost/cost_grpc.py similarity index 54% rename from src/digitalkin/services/cost/grpc_cost.py rename to src/digitalkin/services/cost/cost_grpc.py index 92220b03..e3baad19 100644 --- a/src/digitalkin/services/cost/grpc_cost.py +++ b/src/digitalkin/services/cost/cost_grpc.py @@ -1,20 +1,16 @@ """This module implements the gRPC Cost strategy.""" -from typing import Literal - -from agentic_mesh_protocol.cost.v1 import cost_pb2, cost_service_pb2_grpc +from agentic_mesh_protocol.cost.v1 import cost_dto_pb2, cost_messages_pb2, cost_service_pb2_grpc +from google.protobuf import json_format +from digitalkin.exception.cost import CostServiceError from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.cost import AmountLimit, QuantityLimit +from digitalkin.models.services.cost import AmountLimit, CostConfig, CostData, CostType, QuantityLimit from digitalkin.services.cost.cost_strategy import ( - CostConfig, - CostData, - CostServiceError, CostStrategy, - CostType, ) from digitalkin.utils.proto_utils import proto_to_dict @@ -33,7 +29,9 @@ def __init__( client_config: ClientConfig, ) -> None: """Initialize the cost.""" - super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id) + super().__init__( + mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=client_config + ) self.config = config self._limits: dict[str, QuantityLimit | AmountLimit] = {} self._accumulated: dict[str, float] = {} @@ -41,63 +39,20 @@ def __init__( self.stub = cost_service_pb2_grpc.CostServiceStub(channel) logger.debug("Channel client 'Cost' initialized successfully") - async def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None: - """Set cost limits for this session. - - Args: - limits: List of CostLimit objects to enforce. - """ - self._limits = {limit.name: limit for limit in limits} - self._accumulated = {} - - async def check_limit(self, cost_config_name: str, quantity: float) -> bool: - """Check if adding this cost would exceed any limits. - - Args: - cost_config_name: Name of the cost config. - quantity: Quantity to add. - - Returns: - True if within limits, False if would exceed. - """ - limit = self._limits.get(cost_config_name) - if limit is None: - return True - - cost_config = self.config.get(cost_config_name) - if cost_config is None: - return True - - if limit.limit_type == "quantity": - current = self._accumulated.get(f"{cost_config_name}_quantity", 0) - result = current + quantity <= limit.max_value - logger.debug("debug:check_limit cost_config_name=%s type=quantity result=%s", cost_config_name, result) - return result - - current = self._accumulated.get(f"{cost_config_name}_amount", 0) - projected = cost_config.rate * quantity - result = current + projected <= limit.max_value - logger.debug("debug:check_limit cost_config_name=%s type=amount result=%s", cost_config_name, result) - return result + # ══════════════════════════════════ Publics Methods ═══════════════════════════════════ # - async def add( + async def create( self, name: str, cost_config_name: str, quantity: float, ) -> None: - """Create a new record in the cost database. - - Args: - name: The name of the cost - cost_config_name: The name of the cost config - quantity: The quantity of the cost + """Create a cost record via gRPC. Raises: - CostServiceError: If the cost config is invalid + CostServiceError: If cost config not found or gRPC error. """ - logger.debug("debug:add cost_name=%s cost_config_name=%s quantity=%s", name, cost_config_name, quantity) - async with self.handle_grpc_errors("AddCost", CostServiceError): + async with self.handle_grpc_errors("CreateCost", CostServiceError): cost_config = self.config.get(cost_config_name) if cost_config is None: msg = f"Cost config {cost_config_name} not found in the configuration." @@ -107,85 +62,46 @@ async def add( "name": name, "cost": cost_config.rate * quantity, "unit": cost_config.unit, - "cost_type": CostType[cost_config.cost_type], + "type": cost_config.type, "mission_id": self.mission_id, "rate": cost_config.rate, "quantity": quantity, "setup_version_id": self.setup_version_id, }) - request = cost_pb2.AddCostRequest( + request = cost_dto_pb2.CreateCostRequest( cost=valid_data.cost, name=valid_data.name, unit=valid_data.unit, - cost_type=valid_data.cost_type.name, + type=valid_data.type.name, mission_id=valid_data.mission_id, rate=valid_data.rate, quantity=valid_data.quantity, setup_version_id=valid_data.setup_version_id, ) - await self.exec_grpc_query("AddCost", request) + await self.exec_grpc_query("CreateCost", request) logger.debug("Cost added with cost_dict: %s", valid_data.model_dump()) - async def get(self, name: str) -> list[CostData]: - """Get a record from the database. - - Args: - name: The name of the cost + async def list_config(self) -> list[CostConfig]: + """Retrieve cost configs via gRPC. Returns: - CostData: The cost data + List of cost configurations. """ - async with self.handle_grpc_errors("GetCost", CostServiceError): - request = cost_pb2.GetCostRequest(name=name, mission_id=self.mission_id) - response: cost_pb2.GetCostResponse = await self.exec_grpc_query("GetCost", request) - cost_data_list = [proto_to_dict(cost, with_defaults=True) for cost in response.costs] - logger.debug("Costs retrieved with cost_dict: %s", cost_data_list) - return [CostData.model_validate(cost_data) for cost_data in cost_data_list] - - async def get_filtered( - self, - names: list[str] | None = None, - cost_types: list[Literal["TOKEN_INPUT", "TOKEN_OUTPUT", "API_CALL", "STORAGE", "TIME", "OTHER"]] | None = None, - ) -> list[CostData]: - """Get a list of records from the database. - - Args: - names: The names of the costs - cost_types: The types of the costs - - Returns: - list[CostData]: The cost data - """ - async with self.handle_grpc_errors("GetCosts", CostServiceError): - request = cost_pb2.GetCostsRequest( - mission_id=self.mission_id, - filter=cost_pb2.CostFilter( - names=names or [], - cost_types=cost_types or [], - ), - ) - response: cost_pb2.GetCostsResponse = await self.exec_grpc_query("GetCosts", request) - cost_data_list = [proto_to_dict(cost, with_defaults=True) for cost in response.costs] - logger.debug("Filtered costs retrieved with cost_dict: %s", cost_data_list) - return [CostData.model_validate(cost_data) for cost_data in cost_data_list] - - async def get_cost_config(self) -> list[CostConfig]: - """Get cost configuration from the database. - - Returns: - List of CostConfig objects from the database. - """ - async with self.handle_grpc_errors("GetCostConfig", CostServiceError): - request = cost_pb2.GetCostConfigRequest(setup_version_id=self.setup_version_id) - response: cost_pb2.GetCostConfigResponse = await self.exec_grpc_query("GetCostConfig", request) + async with self.handle_grpc_errors("ListCostConfig", CostServiceError): + request = cost_dto_pb2.ListCostConfigRequest(setup_version_id=self.setup_version_id) + response: cost_dto_pb2.ListCostConfigResponse = await self.exec_grpc_query("ListCostConfig", request) config_list = [] - for config in response.configs: - config_dict = proto_to_dict(config, with_defaults=True) + for config in response.result: + config_dict = json_format.MessageToDict( + config.config, + preserving_proto_field_name=True, + always_print_fields_with_no_presence=True, + ) # Map proto field names to CostConfig field names config_list.append( CostConfig( - cost_name=config_dict.get("name", ""), - cost_type=config_dict.get("cost_type", "OTHER"), + name=config_dict.get("name", ""), + type=config_dict.get("type", CostType.OTHER), description=config_dict.get("description"), unit=config_dict.get("unit", ""), rate=config_dict.get("rate", 0.0), @@ -194,30 +110,92 @@ async def get_cost_config(self) -> list[CostConfig]: logger.debug("Cost configs retrieved: %s", config_list) return config_list - async def set_cost_config(self, configs: list[CostConfig]) -> bool: - """Store cost configuration in the database. - - Args: - configs: List of CostConfig objects to store. + async def set_config(self, configs: list[CostConfig]) -> bool: + """Store cost configs via gRPC. Returns: - True if successfully stored. + True if all configs stored successfully. """ async with self.handle_grpc_errors("SetCostConfig", CostServiceError): proto_configs = [ - cost_pb2.CostConfig( - name=config.cost_name, - cost_type=config.cost_type, + cost_messages_pb2.CostConfig( + name=config.name, + cost_type=config.type, description=config.description or "", unit=config.unit, rate=config.rate, ) for config in configs ] - request = cost_pb2.SetCostConfigRequest( + request = cost_dto_pb2.SetCostConfigRequest( setup_version_id=self.setup_version_id, configs=proto_configs, ) - response: cost_pb2.SetCostConfigResponse = await self.exec_grpc_query("SetCostConfig", request) - logger.debug("Cost configs stored, success: %s", response.success) - return response.success + response: cost_dto_pb2.SetCostConfigResponse = await self.exec_grpc_query("SetCostConfig", request) + if not response.result: + success = False + else: + success = all(getattr(result, "success", False) for result in response.result) + logger.debug("Cost configs stored, success: %s", success) + return success + + async def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None: + """Store cost limits in memory.""" + self._limits = {limit.name: limit for limit in limits} + self._accumulated = {} + + async def check_limit(self, cost_config_name: str, quantity: float) -> bool: + """Check if adding quantity would exceed configured limits. + + Returns: + True if within limits, False if would exceed. + """ + limit = self._limits.get(cost_config_name) + if limit is None: + return True + + cost_config = self.config.get(cost_config_name) + if cost_config is None: + return True + + if limit.limit_type == "quantity": + current = self._accumulated.get(f"{cost_config_name}_quantity", 0) + result = current + quantity <= limit.max_value + logger.debug("debug:check_limit cost_config_name=%s type=quantity result=%s", cost_config_name, result) + return result + + current = self._accumulated.get(f"{cost_config_name}_amount", 0) + projected = cost_config.rate * quantity + result = current + projected <= limit.max_value + logger.debug("debug:check_limit cost_config_name=%s type=amount result=%s", cost_config_name, result) + return result + + async def list( + self, + names: list[str] | None = None, + cost_types: list[CostType] | None = None, + ) -> list[CostData]: + """List cost records filtered by names or types via gRPC. + + Returns: + List of cost data matching filters. + """ + async with self.handle_grpc_errors("ListCosts", CostServiceError): + request = cost_dto_pb2.ListCostsRequest( + mission_id=self.mission_id, + filter=cost_messages_pb2.CostFilter( + names=names or [], + types=[cost_type.to_proto() for cost_type in (cost_types or [])], + ), + ) + response: cost_dto_pb2.ListCostsResponse = await self.exec_grpc_query("ListCosts", request) + cost_data_list = [ + json_format.MessageToDict( + cost_result.cost, + preserving_proto_field_name=True, + always_print_fields_with_no_presence=True, + ) + for cost_result in response.result + ] + logger.debug("Filtered costs retrieved with cost_dict: %s", cost_data_list) + return [CostData.model_validate(cost_data) for cost_data in cost_data_list] diff --git a/src/digitalkin/services/cost/cost_strategy.py b/src/digitalkin/services/cost/cost_strategy.py index 53a33f60..6f74fcba 100644 --- a/src/digitalkin/services/cost/cost_strategy.py +++ b/src/digitalkin/services/cost/cost_strategy.py @@ -1,62 +1,57 @@ """This module contains the abstract base class for cost strategies.""" from abc import ABC, abstractmethod -from enum import Enum -from typing import Literal +from typing import Any -from pydantic import BaseModel +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.services.cost import AmountLimit, CostConfig, CostData, CostType, QuantityLimit -from digitalkin.models.services.cost import AmountLimit, QuantityLimit -from digitalkin.services.base_strategy import BaseStrategy +class CostStrategy(BaseStrategy, ABC): + """Abstract base class for cost strategies.""" -class CostType(Enum): - """Enum defining the types of costs that can be registered.""" - - OTHER = "OTHER" - TOKEN_INPUT = "TOKEN_INPUT" - TOKEN_OUTPUT = "TOKEN_OUTPUT" - API_CALL = "API_CALL" - STORAGE = "STORAGE" - TIME = "TIME" - - -class CostConfig(BaseModel): - """Pydantic model that defines a cost configuration. - - :param cost_name: Name of the cost (unique identifier in the service). - :param cost_type: The type/category of the cost. - :param description: A short description of the cost. - :param unit: The unit of measurement (e.g. token, call, MB). - :param rate: The cost per unit (e.g. dollars per token). - """ - - cost_name: str - cost_type: Literal["TOKEN_INPUT", "TOKEN_OUTPUT", "API_CALL", "STORAGE", "TIME", "OTHER"] - description: str | None = None - unit: str - rate: float + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + config: dict[str, CostConfig], + ) -> None: + """Initialize the strategy. + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version this strategy is associated with + config: Configuration dictionary for the strategy + """ + super().__init__(mission_id, setup_id, setup_version_id) + self.config = config -class CostData(BaseModel): - """Data model for cost operations.""" + # ════════════════════════════════ Public Methods ════════════════════════════════ # - cost: float - mission_id: str - name: str - cost_type: CostType - unit: str - rate: float - setup_version_id: str - quantity: float + @abstractmethod + async def list_config(self) -> list[CostConfig]: + """Get cost configuration from the database. + Returns: + List of CostConfig objects from the database. + """ + msg = "List cost config method not implemented yet." + raise NotImplementedError(msg) -class CostServiceError(Exception): - """Custom exception for CostService errors.""" + @abstractmethod + async def set_config(self, configs: list[CostConfig]) -> bool: + """Store cost configuration in the database. + Args: + configs: List of CostConfig objects to store. -class CostStrategy(BaseStrategy, ABC): - """Abstract base class for cost strategies.""" + Returns: + True if successfully stored. + """ + msg = "Set cost config method not implemented yet." + raise NotImplementedError(msg) @abstractmethod async def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None: @@ -65,6 +60,8 @@ async def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None: Args: limits: List of CostLimit objects to enforce. """ + msg = "Set limits method not implemented yet." + raise NotImplementedError(msg) @abstractmethod async def check_limit(self, cost_config_name: str, quantity: float) -> bool: @@ -77,46 +74,88 @@ async def check_limit(self, cost_config_name: str, quantity: float) -> bool: Returns: True if within limits, False if would exceed. """ + msg = "Check limit method not implemented yet." + raise NotImplementedError(msg) + + # ════════════════════════════════ Overriding Methods ════════════════════════════════ # @abstractmethod - async def add( + async def create( self, name: str, cost_config_name: str, quantity: float, ) -> None: - """Register a new cost.""" + """Create a new record in the cost database. - @abstractmethod - async def get( - self, - name: str, - ) -> list[CostData]: - """Get a cost.""" + Args: + name: The name of the cost + cost_config_name: The name of the cost config + quantity: The quantity of the cost + + Raises: + CostServiceError: If the cost data is invalid or if the cost already exists + """ + return await super().create() @abstractmethod - async def get_filtered( + async def list( self, names: list[str] | None = None, - cost_types: list[Literal["TOKEN_INPUT", "TOKEN_OUTPUT", "API_CALL", "STORAGE", "TIME", "OTHER"]] | None = None, + cost_types: list[CostType] | None = None, ) -> list[CostData]: - """Get filtered costs.""" + """Get records from the database. - @abstractmethod - async def get_cost_config(self) -> list[CostConfig]: - """Get cost configuration for the current setup version. + Args: + names: The names of the costs + cost_types: The types of the costs Returns: - List of CostConfig objects from the database. + list[CostData]: The list of records + + Raises: + CostServiceError: If the cost data is invalid or if the cost does not exist """ + return await super().list() - @abstractmethod - async def set_cost_config(self, configs: list[CostConfig]) -> bool: - """Store cost configuration for the current setup version. + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # - Args: - configs: List of CostConfig objects to store. + async def get(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. Returns: - True if successfully stored. + NotImplementedError from base class. + """ + return await super().get(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().search(args, kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().delete(args, kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().update(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/filesystem/__init__.py b/src/digitalkin/services/filesystem/__init__.py index 5a3d3072..b3683dfd 100644 --- a/src/digitalkin/services/filesystem/__init__.py +++ b/src/digitalkin/services/filesystem/__init__.py @@ -1,7 +1,7 @@ """This module is responsible for handling the filesystem services.""" -from digitalkin.services.filesystem.default_filesystem import DefaultFilesystem +from digitalkin.services.filesystem.filesystem_default import DefaultFilesystem +from digitalkin.services.filesystem.filesystem_grpc import GrpcFilesystem from digitalkin.services.filesystem.filesystem_strategy import FilesystemStrategy -from digitalkin.services.filesystem.grpc_filesystem import GrpcFilesystem __all__ = ["DefaultFilesystem", "FilesystemStrategy", "GrpcFilesystem"] diff --git a/src/digitalkin/services/filesystem/default_filesystem.py b/src/digitalkin/services/filesystem/filesystem_default.py similarity index 64% rename from src/digitalkin/services/filesystem/default_filesystem.py rename to src/digitalkin/services/filesystem/filesystem_default.py index 225c4939..bcd318b1 100644 --- a/src/digitalkin/services/filesystem/default_filesystem.py +++ b/src/digitalkin/services/filesystem/filesystem_default.py @@ -6,16 +6,13 @@ import uuid from typing import Any, Literal +from agentic_mesh_protocol.pagination.v1.pagination_pb2 import PaginationRequest from anyio import Path as AsyncPath +from digitalkin.exception.filesystem import FilesystemServiceError from digitalkin.logger import logger -from digitalkin.services.filesystem.filesystem_strategy import ( - FileFilter, - FilesystemRecord, - FilesystemServiceError, - FilesystemStrategy, - UploadFileData, -) +from digitalkin.models.services.filesystem import FileFilter, FileStatus, FilesystemRecord, FileType, UploadFileData +from digitalkin.services.filesystem.filesystem_strategy import FilesystemStrategy class DefaultFilesystem(FilesystemStrategy): @@ -40,22 +37,10 @@ def __init__(self, mission_id: str, setup_id: str, setup_version_id: str) -> Non self.db: dict[str, FilesystemRecord] = {} logger.debug("DefaultFilesystem initialized with temp_root: %s", self.temp_root) - def _get_context_temp_dir(self, context: str) -> str: - """Get the temporary directory path for a specific context. - - Args: - context: The mission ID or setup ID. - - Returns: - str: Path to the context's temporary directory - """ - # Create a context-specific directory to organize files - context_dir = os.path.join(self.temp_root, context.replace(":", "_")) - os.makedirs(context_dir, exist_ok=True) - return context_dir + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # @staticmethod - def _calculate_checksum(content: bytes) -> str: + def __calculate_checksum(content: bytes) -> str: """Calculate SHA-256 checksum of content. Args: @@ -66,7 +51,7 @@ def _calculate_checksum(content: bytes) -> str: """ return hashlib.sha256(content).hexdigest() - def _filter_db( + def __filter_db( self, filters: FileFilter, ) -> list[FilesystemRecord]: @@ -83,8 +68,8 @@ def _filter_db( f for f in self.db.values() if (not filters.names or f.name in filters.names) - and (not filters.file_ids or f.id in filters.file_ids) - and (not filters.file_types or f.file_type in filters.file_types) + and (not filters.ids or f.id in filters.ids) + and (not filters.types or f.type in filters.types) and (not filters.status or f.status == filters.status) and (not filters.content_type_prefix or f.content_type.startswith(filters.content_type_prefix)) and (not filters.min_size_bytes or f.size_bytes >= filters.min_size_bytes) @@ -93,24 +78,33 @@ def _filter_db( and (not filters.content_type or f.content_type == filters.content_type) ] - async def upload_files( - self, - files: list[UploadFileData], - ) -> tuple[list[FilesystemRecord], int, int]: - """Upload multiple files to the system. + # ══════════════════════════════ Protected Methods ═══════════════════════════════ # - This method allows batch uploading of files with validation and - error handling for each individual file. Files are processed - atomically - if one fails, others may still succeed. + def _get_context_temp_dir(self, context: str) -> str: + """Get the temporary directory path for a specific context. Args: - files: List of files to upload + context: The mission ID or setup ID. Returns: - tuple[list[FilesystemRecord], int, int]: List of uploaded files, total uploaded count, total failed count + str: Path to the context's temporary directory + """ + # Create a context-specific directory to organize files + context_dir = os.path.join(self.temp_root, context.replace(":", "_")) + os.makedirs(context_dir, exist_ok=True) + return context_dir + + async def upload( + self, + files: list[UploadFileData], + ) -> tuple[list[FilesystemRecord], int, int]: + """Upload files to the local filesystem. + + Returns: + Tuple of (uploaded files, upload count, failure count). Raises: - FilesystemServiceError: If there is an error uploading the files + FilesystemServiceError: If a single file upload fails. """ uploaded_files: list[FilesystemRecord] = [] total_uploaded = 0 @@ -124,7 +118,7 @@ async def upload_files( if await AsyncPath(file_path).exists() and not file.replace_if_exists: msg = f"File with name {file.name} already exists." logger.error(msg) - raise FilesystemServiceError(msg) # Intentional: wrap in domain exception for caller # noqa: TRY301 + raise FilesystemServiceError(msg) # noqa: TRY301 await AsyncPath(file_path).write_bytes(file.content) storage_uri = str(await AsyncPath(file_path).resolve()) @@ -132,21 +126,21 @@ async def upload_files( id=str(uuid.uuid4()), context=self.setup_id, name=file.name, - file_type=file.file_type, + type=file.type, content_type=file.content_type or "application/octet-stream", size_bytes=len(file.content), - checksum=self._calculate_checksum(file.content), + checksum=self.__calculate_checksum(file.content), metadata=file.metadata, storage_uri=storage_uri, - file_url=storage_uri, - status="ACTIVE", + url=storage_uri, + status=FileStatus.ACTIVE, ) self.db[file_data.id] = file_data uploaded_files.append(file_data) total_uploaded += 1 logger.debug("Uploaded file %s", file_data) - except Exception as e: # Exception in loop: per-file error isolation in batch upload # noqa: PERF203 + except Exception as e: # noqa: PERF203 logger.exception("Error uploading file %s: %s", file.name, e) total_failed += 1 # If only one file and it failed, propagate the error for pytest.raises @@ -155,149 +149,101 @@ async def upload_files( return uploaded_files, total_uploaded, total_failed - async def get_files( + async def get( self, - filters: FileFilter, + file_id: str, + _context: Literal["mission", "setup"] = "mission", *, - list_size: int = 100, - offset: int = 0, - order: str | None = None, # API interface parameter, not implemented in local filesystem # noqa: ARG002 include_content: bool = False, - ) -> tuple[list[FilesystemRecord], int]: - """List files with filtering, sorting, and pagination. - - This method provides flexible file querying capabilities with support for: - - Multiple filter criteria (name, type, dates, size, etc.) - - Pagination for large result sets - - Sorting by various fields - - Scoped access by context - - Args: - filters: Filter criteria for the files - list_size: Number of files to return per page - offset: Offset to start listing files from - order: Fields to order results by (example: "created_at:asc,name:desc") - include_content: Whether to include file content in response + ) -> FilesystemRecord: + """Retrieve a file by ID from local storage. Returns: - tuple[list[FilesystemRecord], int]: List of files, total count + The requested file record. Raises: - FilesystemServiceError: If there is an error listing the files + FilesystemServiceError: If file not found or retrieval error. """ try: - logger.debug("Listing files with filters: %s", filters) - # Filter files based on provided criteria - filtered_files = self._filter_db(filters) - if not filtered_files: - return [], 0 - # Sorting not implemented for local filesystem (only used in development) + logger.debug("Getting file with id: %s", file_id) + file_data: FilesystemRecord | None = None + if file_id: + file_data = self.db.get(file_id) - # Apply pagination - start_idx = offset - end_idx = start_idx + list_size - paginated_files = filtered_files[start_idx:end_idx] + if not file_data: + msg = f"File not found with id {file_id}" + logger.error(msg) + raise FilesystemServiceError(msg) # noqa: TRY301 if include_content: - for file in paginated_files: - file.content = await AsyncPath(file.storage_uri).read_bytes() + file_path = file_data.storage_uri + if await AsyncPath(file_path).exists(): + file_data.content = await AsyncPath(file_path).read_bytes() except Exception as e: - msg = f"Error listing files: {e!s}" + msg = f"Error getting file: {e!s}" logger.exception(msg) raise FilesystemServiceError(msg) else: - return paginated_files, len(filtered_files) + return file_data - async def get_file( + async def list( self, - file_id: str, - context: Literal["mission", "setup"] = "mission", # noqa: ARG002 + filters: FileFilter, *, + pagination: PaginationRequest = PaginationRequest(limit=100, offset=0, order=None), include_content: bool = False, - ) -> FilesystemRecord: - """Get a specific file by ID or name. - - This method fetches detailed information about a single file, - with optional content inclusion. Supports lookup by either - unique ID or name within a context. - - Args: - file_id: The ID of the file to be retrieved - context: The context of the files (mission or setup) - include_content: Whether to include file content in response + ) -> tuple[list[FilesystemRecord], int]: + """List files matching filters with pagination. Returns: - FilesystemRecord: Metadata about the retrieved file + Tuple of (paginated files, total count). Raises: - FilesystemServiceError: If there is an error retrieving the file + FilesystemServiceError: If listing error occurs. """ try: - logger.debug("Getting file with id: %s", file_id) - file_data: FilesystemRecord | None = None - if file_id: - file_data = self.db.get(file_id) + logger.debug("Listing files with filters: %s", filters) + # Filter files based on provided criteria + filtered_files = self.__filter_db(filters) + if not filtered_files: + return [], 0 + # Sort if order is specified + # TODO - if not file_data: - msg = f"File not found with id {file_id}" - logger.error(msg) - raise FilesystemServiceError(msg) # Intentional: wrap in domain exception for caller # noqa: TRY301 + # Apply pagination + start_idx = pagination.offset + end_idx = start_idx + pagination.limit + paginated_files = filtered_files[start_idx:end_idx] if include_content: - file_path = file_data.storage_uri - if await AsyncPath(file_path).exists(): - file_data.content = await AsyncPath(file_path).read_bytes() + for file in paginated_files: + file.content = await AsyncPath(file.storage_uri).read_bytes() except Exception as e: - msg = f"Error getting file: {e!s}" + msg = f"Error listing files: {e!s}" logger.exception(msg) raise FilesystemServiceError(msg) else: - return file_data + return paginated_files, len(filtered_files) - async def update_file( + async def update( self, file_id: str, content: bytes | None = None, - file_type: Literal[ - "UNSPECIFIED", - "DOCUMENT", - "IMAGE", - "VIDEO", - "AUDIO", - "ARCHIVE", - "CODE", - "OTHER", - ] - | None = None, + type: FileType | None = None, # noqa: A002 content_type: str | None = None, metadata: dict[str, Any] | None = None, new_name: str | None = None, - status: str | None = None, + status: FileStatus | None = None, ) -> FilesystemRecord: - """Update file metadata, content, or both. - - This method allows updating various aspects of a file: - - Rename files - - Update content and content type - - Modify metadata - - Create new versions - - Args: - file_id: The id of the file to be updated - content: Optional new content of the file - file_type: Optional new type of data - content_type: Optional new MIME type - metadata: Optional new metadata (will merge with existing) - new_name: Optional new name for the file - status: Optional new status for the file + """Update file metadata, content, or both in local storage. Returns: - FilesystemRecord: Metadata about the updated file + The updated file record. Raises: - FilesystemServiceError: If there is an error during update + FilesystemServiceError: If file not found or update error. """ logger.debug("Updating file with id: %s", file_id) if file_id not in self.db: @@ -313,10 +259,10 @@ async def update_file( if content is not None: await AsyncPath(file_path).write_bytes(content) existing_file.size_bytes = len(content) - existing_file.checksum = self._calculate_checksum(content) + existing_file.checksum = self.__calculate_checksum(content) - if file_type is not None: - existing_file.file_type = file_type + if type is not None: + existing_file.type = type if content_type is not None: existing_file.content_type = content_type @@ -342,31 +288,20 @@ async def update_file( else: return existing_file - async def delete_files( + async def delete( self, filters: FileFilter, *, permanent: bool = False, - force: bool = False, # API interface parameter, not used in local filesystem # noqa: ARG002 + _force: bool = False, # API interface parameter, not used in local filesystem ) -> tuple[dict[str, bool], int, int]: - """Delete multiple files. - - This method supports batch deletion of files with options for: - - Soft deletion (marking as deleted) - - Permanent deletion - - Force deletion of files in use - - Individual error reporting per file - - Args: - filters: Filter criteria for the files to delete - permanent: Whether to permanently delete the files - force: Whether to force delete even if files are in use + """Delete files matching filters from local storage. Returns: - tuple[dict[str, bool], int, int]: Results per file, total deleted count, total failed count + Tuple of (results dict, deleted count, failed count). Raises: - FilesystemServiceError: If there is an error deleting the files + FilesystemServiceError: If deletion error occurs. """ logger.debug("Deleting files with filters: %s", filters) results: dict[str, bool] = {} # id -> success @@ -375,7 +310,7 @@ async def delete_files( try: # Determine which files to delete - files_to_delete = [f.id for f in self._filter_db(filters)] + files_to_delete = [f.id for f in self.__filter_db(filters)] if not files_to_delete: logger.info("No files match the deletion criteria.") @@ -395,7 +330,7 @@ async def delete_files( await AsyncPath(file_path).unlink() del self.db[file_id] else: - file_data.status = "DELETED" + file_data.status = FileStatus.DELETED self.db[file_id] = file_data results[file_id] = True total_deleted += 1 diff --git a/src/digitalkin/services/filesystem/filesystem_grpc.py b/src/digitalkin/services/filesystem/filesystem_grpc.py new file mode 100644 index 00000000..567f8323 --- /dev/null +++ b/src/digitalkin/services/filesystem/filesystem_grpc.py @@ -0,0 +1,255 @@ +"""gRPC filesystem implementation.""" + +from typing import Any, Literal + +from agentic_mesh_protocol.filesystem.v1 import filesystem_dto_pb2, filesystem_messages_pb2, filesystem_service_pb2_grpc +from agentic_mesh_protocol.pagination.v1.pagination_pb2 import PaginationRequest +from google.protobuf import struct_pb2 +from google.protobuf.json_format import MessageToDict + +from digitalkin.exception.filesystem import FilesystemServiceError +from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper +from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin +from digitalkin.logger import logger +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.filesystem import ( + FileFilter, + FileStatus, + FilesystemRecord, + FileType, + UploadFileData, +) +from digitalkin.services.filesystem.filesystem_strategy import ( + FilesystemStrategy, +) + + +class GrpcFilesystem(FilesystemStrategy, GrpcClientWrapper, GrpcErrorHandlerMixin): + """gRPC client implementation for the Filesystem service.""" + + service_name: str = "FilesystemService" + + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + client_config: ClientConfig, + config: dict[str, Any] | None = None, + ) -> None: + """Initialize the gRPC filesystem strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version this strategy is associated with + client_config: Configuration for the gRPC client connection + config: Configuration for the filesystem strategy + """ + super().__init__(mission_id, setup_id, setup_version_id, config) + self.service_name = "FilesystemService" + channel = self._init_channel(client_config) + self.stub = filesystem_service_pb2_grpc.FilesystemServiceStub(channel) + logger.debug("Channel client 'Filesystem' initialized successfully") + + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # + + @staticmethod + def __file_proto_to_data(file: filesystem_messages_pb2.File) -> FilesystemRecord: + """Convert a File proto message to FilesystemRecord. + + Args: + file: The File proto message to convert + + Returns: + FilesystemRecord: The converted data + """ + return FilesystemRecord( + id=file.id, + context=file.context, + name=file.name, + type=FileType.from_proto(file.type), + content_type=file.content_type, + size_bytes=file.size_bytes, + checksum=file.checksum, + metadata=MessageToDict(file.metadata), + storage_uri=file.storage_uri, + url=file.url, + status=FileStatus.from_proto(file.status), + content=file.content, + ) + + # ════════════════════════════════ Protected Methods ═════════════════════════════════ # + + def _filter_to_proto(self, filters: FileFilter) -> filesystem_messages_pb2.FileFilter: + """Convert a FileFilter to a FileFilter proto message. + + Args: + filters: The FileFilter to convert + + Returns: + filesystem_pb2.FileFilter: The converted FileFilter proto message + """ + context_id = "unknown" + match filters.context: + case "setup": + context_id = self.setup_id + case "mission": + context_id = self.mission_id + return filesystem_messages_pb2.FileFilter( + **filters.model_dump(exclude={"types", "status", "context"}), + types=[file_type.to_proto() for file_type in filters.types] if filters.types else None, + status=filters.status.to_proto() if filters.status else None, + context=context_id, + ) + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def upload( + self, + files: list[UploadFileData], + ) -> tuple[list[FilesystemRecord], int, int]: + """Upload files via gRPC. + + Returns: + Tuple of (uploaded files, upload count, failure count). + """ + logger.debug("Uploading %d files", len(files)) + async with self.handle_grpc_errors("UploadFiles", FilesystemServiceError): + upload_files: list[filesystem_messages_pb2.UploadFileData] = [] + for file in files: + metadata_struct: struct_pb2.Struct | None = None + if file.metadata: + metadata_struct = struct_pb2.Struct() + metadata_struct.update(file.metadata) + upload_files.append( + filesystem_messages_pb2.UploadFileData( + context=self.mission_id, + name=file.name, + type=file.type.to_proto(), + content_type=file.content_type or "application/octet-stream", + content=file.content, + metadata=metadata_struct, + status=FileStatus.UPLOADING.to_proto(), + replace_if_exists=file.replace_if_exists, + ) + ) + request = filesystem_dto_pb2.UploadFilesRequest(files=upload_files) + response: filesystem_dto_pb2.UploadFilesResponse = await self.exec_grpc_query("UploadFiles", request) + results = [self.__file_proto_to_data(result.file) for result in response.result if result.HasField("file")] + logger.debug("Uploaded files: %s", results) + return results, response.bulk.total_process, response.bulk.total_failed + + async def get( + self, + file_id: str, + context: Literal["mission", "setup"] = "mission", + *, + include_content: bool = False, + ) -> FilesystemRecord: + """Retrieve a file by ID via gRPC. + + Returns: + The requested file record. + """ + match context: + case "setup": + context_id = self.setup_id + case "mission": + context_id = self.mission_id + async with self.handle_grpc_errors("GetFile", FilesystemServiceError): + request = filesystem_dto_pb2.GetFileRequest( + context=context_id, + id=file_id, + include_content=include_content, + ) + + response: filesystem_dto_pb2.GetFileResponse = await self.exec_grpc_query("GetFile", request) + + return self.__file_proto_to_data(response.result.file) + + async def list( + self, + filters: FileFilter, + *, + pagination: PaginationRequest = PaginationRequest(limit=100, offset=0, order=None), + include_content: bool = False, + ) -> tuple[list[FilesystemRecord], int]: + """List files matching filters via gRPC. + + Returns: + Tuple of (file records, total count). + """ + match filters.context: + case "setup": + context_id = self.setup_id + case "mission": + context_id = self.mission_id + async with self.handle_grpc_errors("ListFiles", FilesystemServiceError): + request = filesystem_dto_pb2.ListFilesRequest( + context=context_id, + filters=self._filter_to_proto(filters), + include_content=include_content, + pagination=pagination, + ) + response: filesystem_dto_pb2.ListFilesResponse = await self.exec_grpc_query("ListFiles", request) + return [self.__file_proto_to_data(file.file) for file in response.result], response.bulk.total_process + + async def delete( + self, + filters: FileFilter, + *, + permanent: bool = False, + force: bool = False, + ) -> tuple[dict[str, bool], int, int]: + """Delete files matching filters via gRPC. + + Returns: + Tuple of (results dict, deleted count, failed count). + """ + async with self.handle_grpc_errors("DeleteFiles", FilesystemServiceError): + request = filesystem_dto_pb2.DeleteFilesRequest( + context=self.mission_id, + filters=self._filter_to_proto(filters), + permanent=permanent, + force=force, + ) + + response: filesystem_dto_pb2.DeleteFilesResponse = await self.exec_grpc_query("DeleteFiles", request) + + # Extract file IDs from FileResult objects and create results dict + results = {file_result.file.id: True for file_result in response.result} + + return results, response.bulk.total_process, response.bulk.total_failed + + async def update( + self, + file_id: str, + content: bytes | None = None, + type: FileType | None = None, # noqa: A002 + content_type: str | None = None, + metadata: dict[str, Any] | None = None, + new_name: str | None = None, + status: FileStatus | None = None, + ) -> FilesystemRecord: + """Update a file via gRPC. + + Returns: + The updated file record. + """ + async with self.handle_grpc_errors("UpdateFile", FilesystemServiceError): + request = filesystem_dto_pb2.UpdateFileRequest( + context=self.mission_id, + id=file_id, + content=content, + type=type.to_proto() if type else None, + content_type=content_type, + new_name=new_name, + status=status.to_proto() if status else None, + ) + + if metadata: + request.metadata.update(metadata) + + response: filesystem_dto_pb2.UpdateFileResponse = await self.exec_grpc_query("UpdateFile", request) + return self.__file_proto_to_data(response.result.file) diff --git a/src/digitalkin/services/filesystem/filesystem_strategy.py b/src/digitalkin/services/filesystem/filesystem_strategy.py index f09edd74..6bb232a8 100644 --- a/src/digitalkin/services/filesystem/filesystem_strategy.py +++ b/src/digitalkin/services/filesystem/filesystem_strategy.py @@ -1,88 +1,12 @@ """This module contains the abstract base class for filesystem strategies.""" from abc import ABC, abstractmethod -from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, Field - -from digitalkin.services.base_strategy import BaseStrategy - - -class FilesystemServiceError(Exception): - """Base exception for Filesystem service errors.""" - - -class FilesystemRecord(BaseModel): - """Data model for filesystem operations.""" - - id: str = Field(description="Unique identifier for the file (UUID)") - context: str = Field(description="The context of the file in the filesystem") - name: str = Field(description="The name of the file") - file_type: str = Field(default="UNSPECIFIED", description="The type of data stored") - content_type: str = Field(default="application/octet-stream", description="The MIME type of the file") - size_bytes: int = Field(default=0, description="Size of the file in bytes") - checksum: str = Field(default="", description="SHA-256 checksum of the file content") - metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata for the file") - storage_uri: str = Field(description="Internal URI for accessing the file content") - file_url: str = Field(description="Public URL for accessing the file content") - status: str = Field(default="UNSPECIFIED", description="Current status of the file") - content: bytes | None = Field(default=None, description="The content of the file") - - -class FileFilter(BaseModel): - """Filter criteria for querying files.""" - - context: Literal["mission", "setup"] = Field( - default="mission", description="The context of the files (mission or setup)" - ) - names: list[str] | None = Field(default=None, description="Filter by file names (exact matches)") - file_ids: list[str] | None = Field(default=None, description="Filter by file IDs") - file_types: ( - list[ - Literal[ - "UNSPECIFIED", - "DOCUMENT", - "IMAGE", - "AUDIO", - "VIDEO", - "ARCHIVE", - "CODE", - "OTHER", - ] - ] - | None - ) = Field(default=None, description="Filter by file types") - created_after: datetime | None = Field(default=None, description="Filter files created after this timestamp") - created_before: datetime | None = Field(default=None, description="Filter files created before this timestamp") - updated_after: datetime | None = Field(default=None, description="Filter files updated after this timestamp") - updated_before: datetime | None = Field(default=None, description="Filter files updated before this timestamp") - status: str | None = Field(default=None, description="Filter by file status") - content_type_prefix: str | None = Field(default=None, description="Filter by content type prefix (e.g., 'image/')") - min_size_bytes: int | None = Field(default=None, description="Filter files with minimum size") - max_size_bytes: int | None = Field(default=None, description="Filter files with maximum size") - prefix: str | None = Field(default=None, description="Filter by path prefix (e.g., 'folder1/')") - content_type: str | None = Field(default=None, description="Filter by content type") - - -class UploadFileData(BaseModel): - """Data model for uploading a file.""" - - content: bytes = Field(description="The content of the file") - name: str = Field(description="The name of the file") - file_type: Literal[ - "UNSPECIFIED", - "DOCUMENT", - "IMAGE", - "AUDIO", - "VIDEO", - "ARCHIVE", - "CODE", - "OTHER", - ] = Field(description="The type of the file") - content_type: str | None = Field(default=None, description="The content type of the file") - metadata: dict[str, Any] | None = Field(default=None, description="The metadata of the file") - replace_if_exists: bool = Field(default=False, description="Whether to replace the file if it already exists") +from agentic_mesh_protocol.pagination.v1.pagination_pb2 import PaginationRequest + +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.services.filesystem import FileFilter, FileStatus, FilesystemRecord, FileType, UploadFileData class FilesystemStrategy(BaseStrategy, ABC): @@ -100,7 +24,7 @@ def __init__( setup_version_id: str, config: dict[str, Any] | None = None, ) -> None: - """Initialize the strategy. + """Initialize the gRPC filesystem strategy. Args: mission_id: The ID of the mission this strategy is associated with @@ -111,8 +35,10 @@ def __init__( super().__init__(mission_id, setup_id, setup_version_id) self.config = config + # ════════════════════════════════ Overriding Methods ════════════════════════════════ # + @abstractmethod - async def upload_files( + async def upload( self, files: list[UploadFileData], ) -> tuple[list[FilesystemRecord], int, int]: @@ -128,9 +54,10 @@ async def upload_files( Returns: tuple[list[FilesystemRecord], int, int]: List of uploaded files, total uploaded count, total failed count """ + return await super().upload() @abstractmethod - async def get_file( + async def get( self, file_id: str, context: Literal["mission", "setup"] = "mission", @@ -149,17 +76,16 @@ async def get_file( include_content: Whether to include file content in response Returns: - tuple[FilesystemRecord, bytes | None]: Metadata about the retrieved file and optional content + FilesystemRecord: Metadata about the retrieved file """ + return await super().get() @abstractmethod - async def get_files( + async def list( self, filters: FileFilter, *, - list_size: int = 100, - offset: int = 0, - order: str | None = None, + pagination: PaginationRequest = PaginationRequest(limit=100, offset=0, order=None), include_content: bool = False, ) -> tuple[list[FilesystemRecord], int]: """Get multiple files by various criteria. @@ -175,35 +101,50 @@ async def get_files( Args: filters: Filter criteria for the files - list_size: Number of files to return per page - offset: Offset to start listing files from - order: Field to order results by include_content: Whether to include file content in response + pagination: Pagination settings for result set Returns: tuple[list[FilesystemRecord], int]: List of files and total count """ + return await super().list() + + @abstractmethod + async def delete( + self, + filters: FileFilter, + *, + permanent: bool = False, + force: bool = False, + ) -> tuple[dict[str, bool], int, int]: + """Delete multiple files. + + This method supports batch deletion of files with options for: + - Soft deletion (marking as deleted) + - Permanent deletion + - Force deletion of files in use + - Individual error reporting per file + + Args: + filters: Filter criteria for the files + permanent: Whether to permanently delete the files + force: Whether to force delete even if files are in use + + Returns: + tuple[dict[str, bool], int, int]: Results per file, total deleted count, total failed count + """ + return await super().delete() @abstractmethod - async def update_file( + async def update( self, file_id: str, content: bytes | None = None, - file_type: Literal[ - "UNSPECIFIED", - "DOCUMENT", - "IMAGE", - "VIDEO", - "AUDIO", - "ARCHIVE", - "CODE", - "OTHER", - ] - | None = None, + type: FileType | None = None, # noqa: A002 content_type: str | None = None, metadata: dict[str, Any] | None = None, new_name: str | None = None, - status: str | None = None, + status: FileStatus | None = None, ) -> FilesystemRecord: """Update file metadata, content, or both. @@ -216,7 +157,7 @@ async def update_file( Args: file_id: The ID of the file to be updated content: Optional new content of the file - file_type: Optional new type of data + type: Optional new type of data content_type: Optional new MIME type metadata: Optional new metadata (will merge with existing) new_name: Optional new name for the file @@ -225,28 +166,26 @@ async def update_file( Returns: FilesystemRecord: Metadata about the updated file """ + return await super().update() - @abstractmethod - async def delete_files( - self, - filters: FileFilter, - *, - permanent: bool = False, - force: bool = False, - ) -> tuple[dict[str, bool], int, int]: - """Delete multiple files. + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # - This method supports batch deletion of files with options for: - - Soft deletion (marking as deleted) - - Permanent deletion - - Force deletion of files in use - - Individual error reporting per file + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + True after successful deletion. - Args: - filters: Filter criteria for the files - permanent: Whether to permanently delete the files - force: Whether to force delete even if files are in use Returns: - tuple[dict[str, bool], int, int]: Results per file, total deleted count, total failed count + The updated file record. + """ + return await super().create(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. """ + return await super().search(args, kwargs) diff --git a/src/digitalkin/services/filesystem/grpc_filesystem.py b/src/digitalkin/services/filesystem/grpc_filesystem.py deleted file mode 100644 index 5d9e556c..00000000 --- a/src/digitalkin/services/filesystem/grpc_filesystem.py +++ /dev/null @@ -1,324 +0,0 @@ -"""gRPC filesystem implementation.""" - -from typing import Any, Literal - -from agentic_mesh_protocol.filesystem.v1 import filesystem_pb2, filesystem_service_pb2_grpc -from google.protobuf import struct_pb2 -from google.protobuf.json_format import MessageToDict - -from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper -from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin -from digitalkin.logger import logger -from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.services.filesystem.filesystem_strategy import ( - FileFilter, - FilesystemRecord, - FilesystemServiceError, - FilesystemStrategy, - UploadFileData, -) - - -class GrpcFilesystem(FilesystemStrategy, GrpcClientWrapper, GrpcErrorHandlerMixin): - """gRPC client implementation for the Filesystem service.""" - - service_name: str = "FilesystemService" - - @staticmethod - def _file_type_to_enum(file_type: str) -> filesystem_pb2.FileType: - """Convert a file type string to a FileType enum. - - Args: - file_type: The file type string to convert - - Returns: - filesystem_pb2.FileType: The converted file type enum - """ - if not file_type.upper().startswith("FILE_TYPE_"): - file_type = f"FILE_TYPE_{file_type.upper()}" - mapping: dict[str, filesystem_pb2.FileType] = dict[str, Any](filesystem_pb2.FileType.items()) - return mapping.get(file_type.upper(), filesystem_pb2.FileType.FILE_TYPE_UNSPECIFIED) - - @staticmethod - def _file_status_to_enum(file_status: str) -> filesystem_pb2.FileStatus: - """Convert a file status string to a FileStatus enum. - - Args: - file_status: The file status string to convert - - Returns: - filesystem_pb2.FileStatus: The converted file status enum - """ - if not file_status.upper().startswith("FILE_STATUS_"): - file_status = f"FILE_STATUS_{file_status.upper()}" - mapping: dict[str, filesystem_pb2.FileStatus] = dict(filesystem_pb2.FileStatus.items()) # type: ignore[arg-type] - return mapping.get(file_status.upper(), filesystem_pb2.FileStatus.FILE_STATUS_UNSPECIFIED) - - @staticmethod - def _file_proto_to_data(file: filesystem_pb2.File) -> FilesystemRecord: - """Convert a File proto message to FilesystemRecord. - - Args: - file: The File proto message to convert - - Returns: - FilesystemRecord: The converted data - """ - return FilesystemRecord( - id=file.file_id, - context=file.context, - name=file.name, - file_type=filesystem_pb2.FileType.Name(file.file_type), - content_type=file.content_type, - size_bytes=file.size_bytes, - checksum=file.checksum, - metadata=MessageToDict(file.metadata), - storage_uri=file.storage_uri, - file_url=file.file_url, - status=filesystem_pb2.FileStatus.Name(file.status), - content=file.content, - ) - - def _filter_to_proto(self, filters: FileFilter) -> filesystem_pb2.FileFilter: - """Convert a FileFilter to a FileFilter proto message. - - Args: - filters: The FileFilter to convert - - Returns: - filesystem_pb2.FileFilter: The converted FileFilter proto message - """ - context_id = "unknown" - match filters.context: - case "setup": - context_id = self.setup_id - case "mission": - context_id = self.mission_id - return filesystem_pb2.FileFilter( - **filters.model_dump(exclude={"file_types", "status", "context"}), - file_types=[self._file_type_to_enum(file_type) for file_type in filters.file_types] - if filters.file_types - else None, - status=self._file_status_to_enum(filters.status) if filters.status else None, - context=context_id, - ) - - def __init__( - self, - mission_id: str, - setup_id: str, - setup_version_id: str, - client_config: ClientConfig, - config: dict[str, Any] | None = None, - ) -> None: - """Initialize the gRPC filesystem strategy. - - Args: - mission_id: The ID of the mission this strategy is associated with - setup_id: The ID of the setup - setup_version_id: The ID of the setup version this strategy is associated with - client_config: Configuration for the gRPC client connection - config: Configuration for the filesystem strategy - """ - super().__init__(mission_id, setup_id, setup_version_id, config) - self.service_name = "FilesystemService" - channel = self._init_channel(client_config) - self.stub = filesystem_service_pb2_grpc.FilesystemServiceStub(channel) - logger.debug("Channel client 'Filesystem' initialized successfully") - - async def upload_files( - self, - files: list[UploadFileData], - ) -> tuple[list[FilesystemRecord], int, int]: - """Upload multiple files to the filesystem. - - Args: - files: List of tuples containing (content, name, file_type, content_type, metadata, replace_if_exists) - - Returns: - tuple[list[FilesystemRecord], int, int]: List of uploaded files, total uploaded count, total failed count - """ - logger.debug("Uploading %d files", len(files)) - async with self.handle_grpc_errors("UploadFiles", FilesystemServiceError): - upload_files: list[filesystem_pb2.UploadFileData] = [] - for file in files: - metadata_struct: struct_pb2.Struct | None = None - if file.metadata: - metadata_struct = struct_pb2.Struct() - metadata_struct.update(file.metadata) - upload_files.append( - filesystem_pb2.UploadFileData( - context=self.mission_id, - name=file.name, - file_type=self._file_type_to_enum(file.file_type), - content_type=file.content_type or "application/octet-stream", - content=file.content, - metadata=metadata_struct, - status=filesystem_pb2.FileStatus.FILE_STATUS_UPLOADING, - replace_if_exists=file.replace_if_exists, - ) - ) - request = filesystem_pb2.UploadFilesRequest(files=upload_files) - response: filesystem_pb2.UploadFilesResponse = await self.exec_grpc_query("UploadFiles", request) - results = [self._file_proto_to_data(result.file) for result in response.results if result.HasField("file")] - logger.debug("Uploaded files: %s", results) - return results, response.total_uploaded, response.total_failed - - async def get_file( - self, - file_id: str, - context: Literal["mission", "setup"] = "mission", - *, - include_content: bool = False, - ) -> FilesystemRecord: - """Get a file from the filesystem. - - Args: - file_id: The ID of the file to be retrieved - context: The context of the files (mission or setup) - include_content: Whether to include file content in response - - Returns: - FilesystemRecord: Metadata about the retrieved file - - Raises: - FilesystemServiceError: If there is an error retrieving the file - """ - match context: - case "setup": - context_id = self.setup_id - case "mission": - context_id = self.mission_id - logger.debug("debug:get_file file_id=%s context=%s", file_id, context) - async with self.handle_grpc_errors("GetFile", FilesystemServiceError): - request = filesystem_pb2.GetFileRequest( - context=context_id, - file_id=file_id, - include_content=include_content, - ) - - response: filesystem_pb2.GetFileResponse = await self.exec_grpc_query("GetFile", request) - - return self._file_proto_to_data(response.file) - - async def update_file( - self, - file_id: str, - content: bytes | None = None, - file_type: Literal[ - "UNSPECIFIED", - "DOCUMENT", - "IMAGE", - "VIDEO", - "AUDIO", - "ARCHIVE", - "CODE", - "OTHER", - ] - | None = None, - content_type: str | None = None, - metadata: dict[str, Any] | None = None, - new_name: str | None = None, - status: str | None = None, - ) -> FilesystemRecord: - """Update a file in the filesystem. - - Args: - file_id: The id of the file to be updated - content: Optional new content of the file - file_type: Optional new type of data - content_type: Optional new MIME type - metadata: Optional new metadata (will merge with existing) - new_name: Optional new name for the file - status: Optional new status for the file - - Returns: - FilesystemRecord: Metadata about the updated file - - Raises: - FilesystemServiceError: If there is an error during update - """ - async with self.handle_grpc_errors("UpdateFile", FilesystemServiceError): - request = filesystem_pb2.UpdateFileRequest( - context=self.mission_id, - file_id=file_id, - content=content, - file_type=self._file_type_to_enum(file_type) if file_type else None, - content_type=content_type, - new_name=new_name, - status=self._file_status_to_enum(status) if status else None, - ) - - if metadata: - request.metadata.update(metadata) - - response: filesystem_pb2.UpdateFileResponse = await self.exec_grpc_query("UpdateFile", request) - return self._file_proto_to_data(response.result.file) - - async def delete_files( - self, - filters: FileFilter, - *, - permanent: bool = False, - force: bool = False, - ) -> tuple[dict[str, bool], int, int]: - """Delete multiple files from the filesystem. - - Args: - filters: Filter criteria for the files - permanent: Whether to permanently delete the files - force: Whether to force delete even if files are in use - - Returns: - tuple[dict[str, bool], int, int]: Results per file, total deleted count, total failed count - """ - logger.debug("debug:delete_files permanent=%s force=%s", permanent, force) - async with self.handle_grpc_errors("DeleteFiles", FilesystemServiceError): - request = filesystem_pb2.DeleteFilesRequest( - context=self.mission_id, - filters=self._filter_to_proto(filters), - permanent=permanent, - force=force, - ) - - response: filesystem_pb2.DeleteFilesResponse = await self.exec_grpc_query("DeleteFiles", request) - return dict(response.results), response.total_deleted, response.total_failed - - async def get_files( - self, - filters: FileFilter, - *, - list_size: int = 100, - offset: int = 0, - order: str | None = None, - include_content: bool = False, - ) -> tuple[list[FilesystemRecord], int]: - """Get multiple files from the filesystem. - - Args: - filters: Filter criteria for the files - list_size: Number of files to return per page - offset: Offset to start from - order: Field to order results by - include_content: Whether to include file content in response - - Returns: - tuple[list[FilesystemRecord], int]: List of files and total count - """ - match filters.context: - case "setup": - context_id = self.setup_id - case "mission": - context_id = self.mission_id - async with self.handle_grpc_errors("GetFiles", FilesystemServiceError): - request = filesystem_pb2.GetFilesRequest( - context=context_id, - filters=self._filter_to_proto(filters), - include_content=include_content, - list_size=list_size, - offset=offset, - order=order, - ) - response: filesystem_pb2.GetFilesResponse = await self.exec_grpc_query("GetFiles", request) - - return [self._file_proto_to_data(file) for file in response.files], response.total_count diff --git a/src/digitalkin/services/identity/__init__.py b/src/digitalkin/services/identity/__init__.py index 941654a4..4b39c904 100644 --- a/src/digitalkin/services/identity/__init__.py +++ b/src/digitalkin/services/identity/__init__.py @@ -1,6 +1,6 @@ """This module is responsible for handling the identity service.""" -from digitalkin.services.identity.default_identity import DefaultIdentity +from digitalkin.services.identity.identity_default import DefaultIdentity from digitalkin.services.identity.identity_strategy import IdentityStrategy __all__ = ["DefaultIdentity", "IdentityStrategy"] diff --git a/src/digitalkin/services/identity/default_identity.py b/src/digitalkin/services/identity/identity_default.py similarity index 55% rename from src/digitalkin/services/identity/default_identity.py rename to src/digitalkin/services/identity/identity_default.py index 807dd972..761642b7 100644 --- a/src/digitalkin/services/identity/default_identity.py +++ b/src/digitalkin/services/identity/identity_default.py @@ -6,9 +6,9 @@ class DefaultIdentity(IdentityStrategy): """DefaultIdentity is the default identity strategy.""" - async def get_identity( # noqa: PLR6301 - self, - ) -> str: # Default stub implementation; self available for subclass overrides + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def get(self) -> str: """Get the identity. Returns: diff --git a/src/digitalkin/services/identity/identity_strategy.py b/src/digitalkin/services/identity/identity_strategy.py index 9a09dadd..543cc6c5 100644 --- a/src/digitalkin/services/identity/identity_strategy.py +++ b/src/digitalkin/services/identity/identity_strategy.py @@ -1,14 +1,71 @@ """This module contains the abstract base class for identity strategies.""" from abc import ABC, abstractmethod +from typing import Any -from digitalkin.services.base_strategy import BaseStrategy +from digitalkin.models.base_strategy import BaseStrategy class IdentityStrategy(BaseStrategy, ABC): """IdentityStrategy is the abstract base class for all identity strategies.""" + # ════════════════════════════════ Overriding Methods ════════════════════════════════ # + @abstractmethod - async def get_identity(self) -> str: - """Get the identity.""" - ... + async def get(self) -> str: + """Get the identity. + + Returns: + The identity string. + """ + return await super().get() + + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # + + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().create(args, kwargs) + + async def list(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().list(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().search(args, kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().delete(args, kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().update(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/registry/__init__.py b/src/digitalkin/services/registry/__init__.py index 5a1c6978..ae407415 100644 --- a/src/digitalkin/services/registry/__init__.py +++ b/src/digitalkin/services/registry/__init__.py @@ -1,27 +1,21 @@ """This module is responsible for handling the registry service.""" -from digitalkin.models.services.registry import ( - ModuleInfo, - RegistryModuleStatus, - RegistryModuleType, -) -from digitalkin.services.registry.default_registry import DefaultRegistry -from digitalkin.services.registry.exceptions import ( +from digitalkin.exception.registry import ( RegistryModuleNotFoundError, RegistryServiceError, ) -from digitalkin.services.registry.grpc_registry import GrpcRegistry -from digitalkin.services.registry.registry_models import ModuleStatusInfo +from digitalkin.models.services.modules import ModuleInfo, ModuleStatus, ModuleType +from digitalkin.services.registry.registry_default import DefaultRegistry +from digitalkin.services.registry.registry_grpc import GrpcRegistry from digitalkin.services.registry.registry_strategy import RegistryStrategy __all__ = [ "DefaultRegistry", "GrpcRegistry", "ModuleInfo", - "ModuleStatusInfo", + "ModuleStatus", + "ModuleType", "RegistryModuleNotFoundError", - "RegistryModuleStatus", - "RegistryModuleType", "RegistryServiceError", "RegistryStrategy", ] diff --git a/src/digitalkin/services/registry/default_registry.py b/src/digitalkin/services/registry/default_registry.py deleted file mode 100644 index e4874798..00000000 --- a/src/digitalkin/services/registry/default_registry.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Default registry implementation.""" - -from typing import Any - -from digitalkin.models.services.registry import ( - ModuleInfo, - RegistryModuleStatus, - RegistryModuleType, -) -from digitalkin.services.registry.exceptions import RegistryModuleNotFoundError -from digitalkin.services.registry.registry_models import ModuleStatusInfo -from digitalkin.services.registry.registry_strategy import RegistryStrategy - - -class DefaultRegistry(RegistryStrategy): - """Default registry strategy using in-memory storage.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Initialize with per-instance module store.""" - super().__init__(*args, **kwargs) - self._modules: dict[str, ModuleInfo] = {} - - async def discover_by_id(self, module_id: str) -> ModuleInfo: - """Get module info by ID. - - Args: - module_id: The module identifier. - - Returns: - ModuleInfo with module details. - - Raises: - RegistryModuleNotFoundError: If module not found. - """ - if module_id not in self._modules: - raise RegistryModuleNotFoundError(module_id) - return self._modules[module_id] - - async def search( - self, - name: str | None = None, - module_type: str | None = None, - organization_id: str # noqa: ARG002 - | None = None, # Strategy interface parameter, not used in local implementation - ) -> list[ModuleInfo]: - """Search for modules by criteria. - - Args: - name: Filter by name (partial match). - module_type: Filter by type (archetype, tool). - organization_id: Filter by organization (not used in local storage). - - Returns: - List of matching modules. - """ - results = list(self._modules.values()) - - if name: - results = [m for m in results if name in m.module_name] - - if module_type: - results = [m for m in results if m.module_type == module_type] - - return results - - async def get_status(self, module_id: str) -> ModuleStatusInfo: - """Get module status. - - Args: - module_id: The module identifier. - - Returns: - ModuleStatusInfo with current status. - - Raises: - RegistryModuleNotFoundError: If module not found. - """ - if module_id not in self._modules: - raise RegistryModuleNotFoundError(module_id) - - module = self._modules[module_id] - return ModuleStatusInfo( - module_id=module_id, - status=module.status or RegistryModuleStatus.UNSPECIFIED, - ) - - async def register( - self, - module_id: str, - address: str, - port: int, - version: str, - ) -> ModuleInfo | None: - """Register a module with the registry. - - Note: Updates existing module or creates new one in local storage. - - Args: - module_id: Unique module identifier. - address: Network address. - port: Network port. - version: Module version. - - Returns: - ModuleInfo if successful, None otherwise. - """ - existing = self._modules.get(module_id) - self._modules[module_id] = ModuleInfo( - module_id=module_id, - module_type=existing.module_type if existing else RegistryModuleType.UNSPECIFIED, - address=address, - port=port, - version=version, - module_name=existing.module_name if existing else module_id, - status=RegistryModuleStatus.ACTIVE, - ) - return self._modules[module_id] - - async def heartbeat(self, module_id: str) -> RegistryModuleStatus: - """Send heartbeat to keep module active. - - Args: - module_id: The module identifier. - - Returns: - Current module status after heartbeat. - - Raises: - RegistryModuleNotFoundError: If module not found. - """ - if module_id not in self._modules: - raise RegistryModuleNotFoundError(module_id) - - module = self._modules[module_id] - # Update status to ACTIVE on heartbeat - self._modules[module_id] = ModuleInfo( - module_id=module.module_id, - module_type=module.module_type, - address=module.address, - port=module.port, - version=module.version, - module_name=module.module_name, - status=RegistryModuleStatus.ACTIVE, - ) - return RegistryModuleStatus.ACTIVE - - async def deregister(self, module_id: str) -> bool: - """Deregister a module from the registry. - - Args: - module_id: The module identifier to deregister. - - Returns: - True if module was removed, False if not found. - """ - if module_id in self._modules: - del self._modules[module_id] - return True - return False - - async def get_setup(self, setup_id: str) -> None: - """Get setup info (not supported in default registry). - - Args: - setup_id: The setup identifier. - """ diff --git a/src/digitalkin/services/registry/registry_default.py b/src/digitalkin/services/registry/registry_default.py new file mode 100644 index 00000000..d52860ee --- /dev/null +++ b/src/digitalkin/services/registry/registry_default.py @@ -0,0 +1,133 @@ +"""Default registry implementation.""" + +from typing import ClassVar + +from digitalkin.exception.registry import RegistryModuleNotFoundError +from digitalkin.models.services.modules import ModuleInfo, ModuleStatus, ModuleType +from digitalkin.models.services.setup import SetupInfo +from digitalkin.services.registry.registry_strategy import RegistryStrategy + + +class DefaultRegistry(RegistryStrategy): + """Default registry strategy using in-memory storage.""" + + _modules: ClassVar[dict[str, ModuleInfo]] = {} + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def search( + self, + name: str | None = None, + module_type: ModuleType | None = None, + _organization_id: str | None = None, + ) -> list[ModuleInfo]: + """Search modules in local registry by name or type. + + Returns: + The module stub. + """ + results = list(self._modules.values()) + + if name: + results = [m for m in results if name in m.name] + + if module_type: + results = [m for m in results if m.type == module_type] + + return results + + async def get(self, module_id: str) -> ModuleInfo: + """Get module info by ID from local registry. + + Returns: + The module stub. + + Raises: + RegistryModuleNotFoundError: If module not found. + """ + if module_id not in self._modules: + raise RegistryModuleNotFoundError(module_id) + return self._modules[module_id] + + async def get_status(self, module_id: str) -> ModuleStatus: + """Get module status from local registry. + + Returns: + The module stub. + + Raises: + RegistryModuleNotFoundError: If module not found. + """ + if module_id not in self._modules: + raise RegistryModuleNotFoundError(module_id) + + module = self._modules[module_id] + return module.status or ModuleStatus.UNSPECIFIED + + async def register( + self, + module_id: str, + address: str, + port: int, + version: str, + ) -> ModuleInfo | None: + """Register or update a module in local registry. + + Returns: + The module address. + """ + existing = self._modules.get(module_id) + self._modules[module_id] = ModuleInfo( + id=module_id, + type=existing.type if existing else ModuleType.UNSPECIFIED, + address=address, + port=port, + version=version, + name=existing.name if existing else module_id, + status=ModuleStatus.ACTIVE, + ) + return self._modules[module_id] + + async def heartbeat(self, module_id: str) -> ModuleStatus: + """Send heartbeat and return ACTIVE status. + + Returns: + The module class. + + Raises: + RegistryModuleNotFoundError: If module not found. + """ + if module_id not in self._modules: + raise RegistryModuleNotFoundError(module_id) + + module = self._modules[module_id] + # Update status to ACTIVE on heartbeat + self._modules[module_id] = ModuleInfo( + id=module.id, + type=module.type, + address=module.address, + port=module.port, + version=module.version, + name=module.name, + status=ModuleStatus.ACTIVE, + ) + return ModuleStatus.ACTIVE + + async def deregister(self, module_id: str) -> bool: + """Remove module from local registry. + + Returns: + List of registered modules. + """ + if module_id in self._modules: + del self._modules[module_id] + return True + return False + + async def get_setup(self, setup_id: str) -> SetupInfo | None: + """Not implemented. + + Returns: + True after successful deletion. + """ + return await super().get_setup(setup_id=setup_id) diff --git a/src/digitalkin/services/registry/grpc_registry.py b/src/digitalkin/services/registry/registry_grpc.py similarity index 59% rename from src/digitalkin/services/registry/grpc_registry.py rename to src/digitalkin/services/registry/registry_grpc.py index 2d799e0d..3f744c75 100644 --- a/src/digitalkin/services/registry/grpc_registry.py +++ b/src/digitalkin/services/registry/registry_grpc.py @@ -7,30 +7,22 @@ from typing import Any from agentic_mesh_protocol.registry.v1 import ( - registry_enums_pb2, - registry_models_pb2, - registry_requests_pb2, + registry_dto_pb2, + registry_messages_pb2, registry_service_pb2_grpc, ) +from digitalkin.exception.registry import ( + RegistryModuleNotFoundError, + RegistryServiceError, +) from digitalkin.grpc_servers.utils.exceptions import ServerError from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.models.services.registry import ( - ModuleInfo, - RegistryModuleStatus, - RegistryModuleType, - RegistrySetupStatus, - RegistryVisibility, - SetupInfo, -) -from digitalkin.services.registry.exceptions import ( - RegistryModuleNotFoundError, - RegistryServiceError, -) -from digitalkin.services.registry.registry_models import ModuleStatusInfo +from digitalkin.models.services.modules import ModuleInfo, ModuleStatus, ModuleType +from digitalkin.models.services.setup import SetupInfo, SetupStatus, Visibility from digitalkin.services.registry.registry_strategy import RegistryStrategy @@ -57,9 +49,11 @@ def __init__( self.stub = registry_service_pb2_grpc.RegistryServiceStub(self._init_channel(client_config)) logger.debug("Channel client 'Registry' initialized successfully") + # ════════════════════════════════ Private Methods ═════════════════════════════════ # + @staticmethod - def _proto_to_module_info( - descriptor: registry_models_pb2.ModuleDescriptor, + def __proto_to_module_info( + descriptor: registry_messages_pb2.ModuleDescriptor, ) -> ModuleInfo: """Convert proto ModuleDescriptor to ModuleInfo. @@ -69,19 +63,19 @@ def _proto_to_module_info( Returns: ModuleInfo with mapped fields. """ - type_name = registry_enums_pb2.ModuleType.Name(descriptor.module_type).removeprefix("MODULE_TYPE_") return ModuleInfo( - module_id=descriptor.id, - module_type=RegistryModuleType[type_name], + id=descriptor.id, + type=ModuleType.from_proto(descriptor.type), address=descriptor.address, port=descriptor.port, version=descriptor.version, - module_name=descriptor.name, + name=descriptor.name, documentation=descriptor.documentation or None, + status=ModuleStatus.from_proto(descriptor.status), ) @staticmethod - def _proto_to_setup_info(descriptor: registry_models_pb2.SetupDescriptor) -> SetupInfo | None: + def __proto_to_setup_info(descriptor: registry_messages_pb2.SetupDescriptor) -> SetupInfo | None: """Convert proto SetupDescriptor to SetupInfo. Args: @@ -92,14 +86,12 @@ def _proto_to_setup_info(descriptor: registry_models_pb2.SetupDescriptor) -> Set """ if not descriptor.id: return None - status_name = registry_enums_pb2.SetupStatus.Name(descriptor.status).removeprefix("SETUP_STATUS_") - visibility_name = registry_enums_pb2.Visibility.Name(descriptor.visibility).removeprefix("VISIBILITY_") return SetupInfo( setup_id=descriptor.id, name=descriptor.name, documentation=descriptor.documentation or None, - status=RegistrySetupStatus[status_name], - visibility=RegistryVisibility[visibility_name], + status=SetupStatus.from_proto(descriptor.status), + visibility=Visibility.from_proto(descriptor.visibility), organization_id=descriptor.organization_id or None, owner_id=descriptor.owner_id or None, card_id=descriptor.card_id or None, @@ -109,64 +101,21 @@ def _proto_to_setup_info(descriptor: registry_models_pb2.SetupDescriptor) -> Set config=dict(descriptor.config) if descriptor.config else None, ) - async def discover_by_id(self, module_id: str) -> ModuleInfo: - """Get module info by ID. - - Args: - module_id: The module identifier. - - Returns: - ModuleInfo with module details. - - Raises: - RegistryModuleNotFoundError: If module not found. - RegistryServiceError: If gRPC call fails. - """ - logger.debug("Discovering module by ID", extra={"module_id": module_id}) - - async with self.handle_grpc_errors("GetModule", RegistryServiceError): - try: - response = await self.exec_grpc_query( - "GetModule", - registry_requests_pb2.GetModuleRequest(module_id=module_id), - ) - except ServerError as e: - msg = f"Failed to discover module '{module_id}': {e}" - logger.error(msg) - raise RegistryServiceError(msg) from e - - if not response.id: - logger.warning("Module not found in registry", extra={"module_id": module_id}) - raise RegistryModuleNotFoundError(module_id) - - logger.debug( - "Module discovered", - extra={ - "module_id": response.id, - "address": response.address, - "port": response.port, - }, - ) - return self._proto_to_module_info(response) + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # async def search( self, name: str | None = None, - module_type: str | None = None, + module_type: ModuleType | None = None, organization_id: str | None = None, ) -> list[ModuleInfo]: - """Search for modules by criteria. - - Args: - name: Filter by name (partial match via query). - module_type: Filter by type (archetype, tool). - organization_id: Filter by organization. + """Search modules via gRPC registry. Returns: - List of matching modules. + Registration response. Raises: - RegistryServiceError: If gRPC call fails. + RegistryServiceError: If gRPC error. """ logger.debug( "Searching modules", @@ -177,16 +126,15 @@ async def search( }, ) - async with self.handle_grpc_errors("DiscoverModules", RegistryServiceError): - module_types: list[str] = [] + async with self.handle_grpc_errors("SearchModules", RegistryServiceError): + module_types = [] if module_type: - enum_val = RegistryModuleType[module_type.upper()] - module_types.append(f"MODULE_TYPE_{enum_val.name}") + module_types.append(module_type.to_proto()) try: response = await self.exec_grpc_query( - "DiscoverModules", - registry_requests_pb2.DiscoverModulesRequest( + "SearchModules", + registry_dto_pb2.SearchModulesRequest( query=name or "", organization_id=organization_id or "", module_types=module_types, @@ -197,48 +145,79 @@ async def search( logger.error(msg) raise RegistryServiceError(msg) from e - logger.debug("Search returned %d modules", len(response.modules)) - return [self._proto_to_module_info(m) for m in response.modules] + logger.debug("Search returned %d modules", len(response.result)) + return [self.__proto_to_module_info(m.module_descriptor) for m in response.result] - async def get_status(self, module_id: str) -> ModuleStatusInfo: - """Get module status by fetching the module. - - Args: - module_id: The module identifier. + async def get(self, module_id: str) -> ModuleInfo: + """Get module info by ID via gRPC. Returns: - ModuleStatusInfo with current status. + The module stub. Raises: RegistryModuleNotFoundError: If module not found. - RegistryServiceError: If gRPC call fails. + RegistryServiceError: If gRPC error. """ - logger.debug("Getting module status", extra={"module_id": module_id}) + logger.debug("Getting module by ID", extra={"id": module_id}) async with self.handle_grpc_errors("GetModule", RegistryServiceError): try: response = await self.exec_grpc_query( "GetModule", - registry_requests_pb2.GetModuleRequest(module_id=module_id), + registry_dto_pb2.GetModuleRequest(module_id=module_id), + ) + except ServerError as e: + msg = f"Failed to discover module '{module_id}': {e}" + logger.error(msg) + raise RegistryServiceError(msg) from e + + if not response.result.success: + logger.warning("Module not found in registry", extra={"module_id": module_id}) + raise RegistryModuleNotFoundError(module_id) + + logger.debug( + "Module discovered", + extra={ + "module_id": response.result.module_descriptor.id, + "address": response.result.module_descriptor.address, + "port": response.result.module_descriptor.port, + }, + ) + return self.__proto_to_module_info(response.result.module_descriptor) + + async def get_status(self, module_id: str) -> ModuleStatus: + """Get module status via gRPC. + + Returns: + The module stub. + + Raises: + RegistryModuleNotFoundError: If module not found. + RegistryServiceError: If gRPC error. + """ + logger.debug("Getting module status", extra={"module_id": module_id}) + + async with self.handle_grpc_errors("GetStatus", RegistryServiceError): + try: + response = await self.exec_grpc_query( + "GetModuleStatus", + registry_dto_pb2.GetModuleRequest(module_id=module_id), ) except ServerError as e: msg = f"Failed to get module status for '{module_id}': {e}" logger.error(msg) raise RegistryServiceError(msg) from e - if not response.id: + if not response.result.success: logger.warning("Module not found in registry", extra={"module_id": module_id}) raise RegistryModuleNotFoundError(module_id) - status_name = registry_enums_pb2.ModuleStatus.Name(response.status).removeprefix("MODULE_STATUS_") + status_name = ModuleStatus.from_proto(response.result.module_descriptor.status) logger.debug( "Module status retrieved", - extra={"module_id": response.id, "status": status_name}, - ) - return ModuleStatusInfo( - module_id=response.id, - status=RegistryModuleStatus[status_name], + extra={"module_id": response.result.module_descriptor.id, "status": status_name}, ) + return status_name async def register( self, @@ -247,22 +226,13 @@ async def register( port: int, version: str, ) -> ModuleInfo | None: - """Register a module with the registry. - - Note: The new proto only updates address/port/version for an existing module. - The module must already exist in the registry database. - - Args: - module_id: Unique module identifier. - address: Network address. - port: Network port. - version: Module version. + """Register a module via gRPC. Returns: - ModuleInfo if successful, None if module not found. + The module address. Raises: - RegistryServiceError: If gRPC call fails. + RegistryServiceError: If gRPC error. """ logger.info( "Registering module with registry", @@ -278,7 +248,7 @@ async def register( try: response = await self.exec_grpc_query( "RegisterModule", - registry_requests_pb2.RegisterModuleRequest( + registry_dto_pb2.RegisterModuleRequest( module_id=module_id, address=address, port=port, @@ -290,7 +260,7 @@ async def register( logger.error(msg) raise RegistryServiceError(msg) from e - if not response.module or not response.module.id: + if not response.result.success: logger.warning( "Registry returned empty response for module registration", extra={"module_id": module_id}, @@ -300,24 +270,21 @@ async def register( logger.info( "Module registered successfully", extra={ - "module_id": response.module.id, - "address": response.module.address, - "port": response.module.port, + "module_id": response.result.module_descriptor.id, + "address": response.result.module_descriptor.address, + "port": response.result.module_descriptor.port, }, ) - return self._proto_to_module_info(response.module) + return self.__proto_to_module_info(response.result.module_descriptor) - async def heartbeat(self, module_id: str) -> RegistryModuleStatus: - """Send heartbeat to keep module active. - - Args: - module_id: The module identifier. + async def heartbeat(self, module_id: str) -> ModuleStatus: + """Send heartbeat via gRPC and return module status. Returns: - Current module status after heartbeat. + Module class instance. Raises: - RegistryServiceError: If gRPC call fails. + RegistryServiceError: If gRPC error. """ logger.debug("Sending heartbeat", extra={"module_id": module_id}) @@ -325,59 +292,49 @@ async def heartbeat(self, module_id: str) -> RegistryModuleStatus: try: response = await self.exec_grpc_query( "Heartbeat", - registry_requests_pb2.HeartbeatRequest(module_id=module_id), + registry_dto_pb2.HeartbeatRequest(module_id=module_id), ) except ServerError as e: msg = f"Failed to send heartbeat for '{module_id}': {e}" logger.error(msg) raise RegistryServiceError(msg) from e - status_name = registry_enums_pb2.ModuleStatus.Name(response.status).removeprefix("MODULE_STATUS_") + status_name = ModuleStatus.from_proto(response.status) logger.debug( "Heartbeat response", extra={"module_id": module_id, "status": status_name}, ) - return RegistryModuleStatus[status_name] + return status_name async def get_setup(self, setup_id: str) -> SetupInfo | None: - """Get setup info. - - Args: - setup_id: The setup identifier. + """Get setup info via gRPC. Returns: - SetupInfo if successful, None otherwise. + List of registered modules. Raises: - RegistryServiceError: If gRPC call fails. + RegistryServiceError: If gRPC error. """ logger.debug("Getting setup", extra={"setup_id": setup_id}) async with self.handle_grpc_errors("GetSetup", RegistryServiceError): try: response = await self.exec_grpc_query( "GetSetup", - registry_requests_pb2.GetSetupRequest(setup_id=setup_id), + registry_dto_pb2.GetSetupRequest(setup_id=setup_id), ) except ServerError as e: msg = f"Failed to get setup '{setup_id}': {e}" logger.error(msg) raise RegistryServiceError(msg) from e - return self._proto_to_setup_info(response) + return self.__proto_to_setup_info(response) - async def deregister( # noqa: PLR6301 + async def deregister( self, module_id: str - ) -> bool: # Protocol uses heartbeat expiration; self available for future override - """Deregister a module from the registry. - - Note: The registry protocol uses heartbeat expiration for deregistration. - When a module stops sending heartbeats, it becomes inactive. This method - logs the deregistration intent for observability. - - Args: - module_id: The module identifier to deregister. + ) -> bool: + """Log deregistration intent (heartbeat expiration handles actual removal). Returns: - True always (heartbeat expiration handles actual deregistration). + True after successful deletion. """ logger.info( "Module deregistration initiated (will become inactive via heartbeat expiration)", diff --git a/src/digitalkin/services/registry/registry_models.py b/src/digitalkin/services/registry/registry_models.py deleted file mode 100644 index 35187e02..00000000 --- a/src/digitalkin/services/registry/registry_models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Registry data models. - -This module contains Pydantic models for registry service data structures. -""" - -from pydantic import BaseModel - -from digitalkin.models.services.registry import RegistryModuleStatus - - -class ModuleStatusInfo(BaseModel): - """Module status response.""" - - module_id: str - status: RegistryModuleStatus diff --git a/src/digitalkin/services/registry/registry_strategy.py b/src/digitalkin/services/registry/registry_strategy.py index 8cccdba3..155ffeca 100644 --- a/src/digitalkin/services/registry/registry_strategy.py +++ b/src/digitalkin/services/registry/registry_strategy.py @@ -3,13 +3,9 @@ from abc import ABC, abstractmethod from typing import Any -from digitalkin.models.services.registry import ( - ModuleInfo, - RegistryModuleStatus, - SetupInfo, -) -from digitalkin.services.base_strategy import BaseStrategy -from digitalkin.services.registry.registry_models import ModuleStatusInfo +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.services.modules import ModuleInfo, ModuleStatus, ModuleType +from digitalkin.models.services.setup import SetupInfo class RegistryStrategy(BaseStrategy, ABC): @@ -30,16 +26,13 @@ def __init__( super().__init__(mission_id, setup_id, setup_version_id) self.config = config - @abstractmethod - async def discover_by_id(self, module_id: str) -> ModuleInfo: - """Get module info by ID.""" - ... + # ════════════════════════════════ Overriding Methods ════════════════════════════════ # @abstractmethod async def search( self, name: str | None = None, - module_type: str | None = None, + module_type: ModuleType | None = None, organization_id: str | None = None, ) -> list[ModuleInfo]: """Search for modules by criteria. @@ -50,14 +43,26 @@ async def search( organization_id: Filter by organization. Returns: - List of matching modules. + list[ModuleInfo]: List of matching modules. """ - ... + return await super().search() @abstractmethod - async def get_status(self, module_id: str) -> ModuleStatusInfo: - """Get module status.""" - ... + async def get(self, module_id: str) -> ModuleInfo: + """Get module information by its unique identifier. + + Args: + module_id: Unique module identifier. + + Returns: + ModuleInfo: If module with the given ID is found in the registry. + + Raises: + RegistryModuleNotFoundError: If module with the given ID is not found in the registry. + """ + return await super().get() + + # ════════════════════════════════ Abstracts Methods ═════════════════════════════════ # @abstractmethod async def register( @@ -79,38 +84,114 @@ async def register( version: Module version. Returns: - ModuleInfo if successful, None otherwise. + ModuleInfo: If registration successful """ - ... + msg = "Register method not implemented yet." + raise NotImplementedError(msg) @abstractmethod - async def heartbeat(self, module_id: str) -> RegistryModuleStatus: + async def heartbeat(self, module_id: str) -> ModuleStatus: """Send heartbeat to keep module active. Args: module_id: The module identifier. Returns: - Current module status after heartbeat. + ModuleStatus: Current module status after heartbeat. Raises: RegistryModuleNotFoundError: If module not found. """ - ... + msg = "Heartbeat method not implemented yet." + raise NotImplementedError(msg) + + @abstractmethod + async def get_status(self, module_id: str) -> ModuleInfo: + """Get the current status of a module. + + Args: + module_id: The module identifier. + + Returns: + ModuleInfo: Current module information including status. + + Raises: + RegistryModuleNotFoundError: If module not found. + """ + msg = "Get status method not implemented yet." + raise NotImplementedError(msg) @abstractmethod async def get_setup(self, setup_id: str) -> SetupInfo | None: - """Get setup info.""" - ... + """Get setup info. + + Args: + setup_id: The setup identifier. + + Returns: + SetupInfo if successful, None otherwise. + + Raises: + RegistryServiceError: If gRPC call fails. + """ + msg = "Get setup method not implemented yet." + raise NotImplementedError(msg) @abstractmethod async def deregister(self, module_id: str) -> bool: """Deregister a module from the registry. + Note: The registry protocol uses heartbeat expiration for deregistration. + When a module stops sending heartbeats, it becomes inactive. This method + logs the deregistration intent for observability. + Args: module_id: The module identifier to deregister. Returns: - True if deregistration was successful, False otherwise. + True always (heartbeat expiration handles actual deregistration). + """ + msg = "Deregister method not implemented yet." + raise NotImplementedError(msg) + + # ════════════════════════════ Unimplemented Methods ═════════════════════════════ # + + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().create(args, kwargs) + + async def list(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().list(args, kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().delete(args, kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().update(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. """ - ... + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/setup/default_setup.py b/src/digitalkin/services/setup/default_setup.py deleted file mode 100644 index 183af0ff..00000000 --- a/src/digitalkin/services/setup/default_setup.py +++ /dev/null @@ -1,234 +0,0 @@ -"""This module contains the abstract base class for setup strategies.""" - -import secrets -import string -from typing import Any - -from pydantic import ValidationError - -from digitalkin.logger import logger -from digitalkin.services.setup.setup_strategy import SetupData, SetupServiceError, SetupStrategy, SetupVersionData - - -class DefaultSetup(SetupStrategy): - """Abstract base class for setup strategies.""" - - setups: dict[str, SetupData] - setup_versions: dict[str, dict[str, SetupVersionData]] - - def __init__(self) -> None: - """Initialize the default setup strategy.""" - super().__init__() - self.setups = {} - self.setup_versions = {} - - async def create_setup(self, setup_dict: dict[str, Any]) -> str: - """Create a new setup with comprehensive validation. - - Args: - setup_dict: Dictionary containing setup details. - - Returns: - bool: Success status of setup creation. - - Raises: - ValidationError: If setup data is invalid. - GrpcOperationError: If gRPC operation fails. - """ - try: - valid_data = SetupData.model_validate(setup_dict["data"]) # Revalidates instance - except ValidationError: - logger.exception("Validation failed for model SetupData") - return "" - - setup_id = setup_dict.get( - "setup_id", "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) - ) - valid_data.id = setup_id - self.setups[setup_id] = valid_data - logger.debug("CREATE SETUP DATA %s:%s successful", setup_id, valid_data) - return setup_id - - async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: - """Retrieve a setup by its unique identifier. - - Args: - setup_dict: Dictionary with 'name' and optional 'version'. - - Returns: - Dict[str, Any]: Setup details including optional setup version. - - Raises: - SetupServiceError: setup_id does not exist. - """ - logger.debug("GET setup_id = %s", setup_dict["setup_id"]) - if setup_dict["setup_id"] not in self.setups: - msg = f"GET setup_id = {setup_dict['setup_id']}: setup_id DOESN'T EXIST" - logger.error(msg) - raise SetupServiceError(msg) - return self.setups[setup_dict["setup_id"]] - - async def update_setup(self, setup_dict: dict[str, Any]) -> bool: - """Update an existing setup. - - Args: - setup_dict: Dictionary with setup update details. - - Returns: - bool: Success status of the update operation. - - Raises: - ValidationError: setup object failed validation. - """ - if setup_dict["setup_id"] not in self.setups: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_dict["setup_id"]) - return False - - try: - valid_data = SetupData.model_validate(setup_dict["data"]) # Revalidates instance - except ValidationError: - logger.exception("Validation failed for model SetupData") - return False - - self.setups[setup_dict["update_id"]] = valid_data - return True - - async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: - """Delete a setup by its unique identifier. - - Args: - setup_dict: Dictionary with the setup 'name'. - - Returns: - bool: Success status of deletion. - """ - if setup_dict["setup_id"] not in self.setups: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_dict["setup_id"]) - return False - del self.setups[setup_dict["setup_id"]] - return True - - async def create_setup_version(self, setup_version_dict: dict[str, Any]) -> str: - """Create a new setup version. - - Args: - setup_version_dict: Dictionary with setup version details. - - Returns: - str: version of setup version creation. - - Raises: - SetupServiceError: setup object failed validation. - """ - try: - valid_data = SetupVersionData.model_validate(setup_version_dict["data"]) # Revalidates instance - except ValidationError: - msg = "Validation failed for model SetupVersionData" - logger.exception(msg) - raise SetupServiceError(msg) - - if setup_version_dict["setup_id"] not in self.setup_versions: - self.setup_versions[setup_version_dict["setup_id"]] = {} - self.setup_versions[setup_version_dict["setup_id"]][valid_data.version] = valid_data - logger.debug("CREATE SETUP VERSION DATA %s:%s successful", setup_version_dict["setup_id"], valid_data) - return valid_data.version - - async def get_setup_version(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: - """Retrieve a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'name'. - - Returns: - Dict[str, Any]: Setup version details. - - Raises: - SetupServiceError: setup_id does not exist. - """ - logger.debug("GET setup_id = %s: version = %s", setup_version_dict["setup_id"], setup_version_dict["version"]) - if setup_version_dict["setup_id"] not in self.setup_versions: - msg = f"GET setup_id = {setup_version_dict['setup_id']}: setup_id DOESN'T EXIST" - logger.error(msg) - raise SetupServiceError(msg) - - return self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] - - async def search_setup_versions(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: - """Search for setup versions based on filters. - - Args: - setup_version_dict: Dictionary with optional 'name' or 'query_versions' filters. - - Returns: - List[SetupVersionData]: A list of matching setup version details. - - Raises: - SetupServiceError: setup_id does not exist. - """ - if setup_version_dict["setup_id"] not in self.setup_versions: - msg = f"GET setup_id = {setup_version_dict['setup_id']}: setup_id DOESN'T EXIST" - logger.error(msg) - raise SetupServiceError(msg) - - return [ - value - for value in self.setup_versions[setup_version_dict["setup_id"]].values() - if setup_version_dict["query_versions"] in value.version - ] - - async def update_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Update an existing setup version. - - Args: - setup_version_dict: Dictionary with setup version update details. - - Returns: - bool: Success status of the update operation. - """ - if setup_version_dict["setup_id"] not in self.setup_versions: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) - return False - - if setup_version_dict["version"] not in self.setup_versions[setup_version_dict["setup_id"]]: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) - return False - - try: - valid_data = SetupVersionData.model_validate(setup_version_dict["data"]) - except ValidationError: - logger.exception("Validation failed for model SetupVersionData") - return False - - self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] = valid_data - return True - - async def delete_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Delete a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'name'. - - Returns: - bool: Success status of version deletion. - """ - if setup_version_dict["setup_id"] not in self.setup_versions: - logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) - return False - - del self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] - return True - - async def list_setups(self, list_dict: dict[str, Any]) -> dict[str, Any]: - """List setups with optional filtering and pagination. - - Args: - list_dict: Dictionary with optional filters. - - Returns: - dict[str, Any]: Dictionary with 'setups' list and 'total_count'. - """ - setups = list(self.setups.values()) - offset = list_dict.get("offset", 0) - limit = list_dict.get("limit", 0) - setups = setups[offset : offset + limit] if limit > 0 else setups[offset:] - return {"setups": [s.model_dump() for s in setups], "total_count": len(self.setups)} diff --git a/src/digitalkin/services/setup/grpc_setup.py b/src/digitalkin/services/setup/grpc_setup.py deleted file mode 100644 index df3b65fb..00000000 --- a/src/digitalkin/services/setup/grpc_setup.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Digital Kin Setup Service gRPC Client.""" - -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import Any - -import grpc -from agentic_mesh_protocol.setup.v1 import ( - setup_pb2, - setup_service_pb2_grpc, -) -from google.protobuf import json_format -from google.protobuf.struct_pb2 import Struct -from pydantic import ValidationError - -from digitalkin.grpc_servers.utils.exceptions import ServerError -from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper -from digitalkin.logger import logger -from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.services.setup.setup_strategy import SetupData, SetupServiceError, SetupStrategy, SetupVersionData -from digitalkin.utils.proto_utils import proto_to_dict - - -class GrpcSetup(SetupStrategy, GrpcClientWrapper): - """gRPC client implementation for the Setup service. - - Communicates with the remote SetupService gRPC server to manage - setup configurations and versions. - """ - - service_name: str = "SetupService" - - def __post_init__(self, config: ClientConfig) -> None: - """Init the channel from a config file. - - Need to be call if the user register a gRPC channel. - """ - channel = self._init_channel(config) - self.stub = setup_service_pb2_grpc.SetupServiceStub(channel) - logger.debug("Channel client 'setup' initialized successfully") - - @asynccontextmanager - async def handle_grpc_errors( # noqa: PLR6301 - self, operation: str - ) -> AsyncGenerator[Any, Any]: # Mixin: self available for subclass overrides - """Context manager for consistent gRPC error handling with detailed logging. - - Args: - operation: Description of the operation being performed (e.g., "Get Setup", "Create Setup Version"). - - Yields: - Allow error handling in context. - - Raises: - ValueError: Pydantic model validation failed - input data is malformed. - ServerError: gRPC communication failed - remote service returned error or is unreachable. - SetupServiceError: Unexpected error during setup operation - includes connection/timeout issues. - """ - try: - yield - except ValidationError as e: - msg = f"Validation failed for {operation}: {e}" - logger.error( - "ValidationError in %s: %s", - operation, - e, - extra={"operation": operation, "error_type": "ValidationError", "service_name": "SetupService"}, - ) - raise ValueError(msg) from e - except grpc.RpcError as e: - status_code = e.code().name if e.code() else "UNKNOWN" - details = e.details() or str(e) - msg = f"gRPC {operation} [{status_code}]: {details}" - logger.error( - "gRPC %s [%s]: %s", - operation, - status_code, - details, - extra={"operation": operation, "error_type": "grpc.RpcError", "grpc_code": status_code}, - ) - raise ServerError(msg) from e - except (TimeoutError, ConnectionError, OSError) as e: - error_type = type(e).__name__ - msg = f"{error_type} in {operation}: {e}" - logger.error( - "%s in %s: %s", - error_type, - operation, - e, - extra={"operation": operation, "error_type": error_type, "service_name": "SetupService"}, - ) - raise SetupServiceError(msg) from e - except Exception as e: - error_type = type(e).__name__ - msg = f"Unexpected {error_type} in {operation}: {e}" - logger.error( - "Unexpected %s in %s: %s", - error_type, - operation, - e, - extra={"operation": operation, "error_type": error_type, "service_name": "SetupService"}, - exc_info=True, - ) - raise SetupServiceError(msg) from e - - async def create_setup(self, setup_dict: dict[str, Any]) -> str: - """Create a new setup with comprehensive validation. - - Args: - setup_dict: Dictionary containing setup details. - - Returns: - bool: Success status of setup creation. - - Raises: - ValidationError: If setup data is invalid. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Setup Creation"): - valid_data = SetupData.model_validate(setup_dict) - - request = setup_pb2.CreateSetupRequest( - name=valid_data.name, - organisation_id=valid_data.organisation_id, - owner_id=valid_data.owner_id, - module_id=valid_data.module_id, - current_setup_version=setup_pb2.SetupVersion(**valid_data.current_setup_version.model_dump()), - ) - response = await self.exec_grpc_query("CreateSetup", request) - logger.debug("Setup '%s' query sent successfully", valid_data.name) - return response - - async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: - """Retrieve a setup by its unique identifier. - - Args: - setup_dict: Dictionary with 'name' and optional 'version'. - - Returns: - dict[str, Any]: Setup details including optional setup version. - - Raises: - ValidationError: If the setup name is missing. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Get Setup"): - if "setup_id" not in setup_dict: - msg = "Setup name is required" - raise ValidationError(msg) - - request = setup_pb2.GetSetupRequest( - setup_id=setup_dict["setup_id"], - version=setup_dict.get("version", ""), - ) - response = await self.exec_grpc_query("GetSetup", request) - response_data = proto_to_dict(response) - return SetupData(**response_data["setup"]) - - async def update_setup(self, setup_dict: dict[str, Any]) -> bool: - """Update an existing setup. - - Args: - setup_dict: Dictionary with setup update details. - - Returns: - bool: Success status of the update operation. - - Raises: - ValidationError: If setup data is invalid. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - current_setup_version = None - - async with self.handle_grpc_errors("Setup Update"): - valid_data = SetupData.model_validate(setup_dict) - - if valid_data.current_setup_version is not None: - current_setup_version = setup_pb2.SetupVersion(**valid_data.current_setup_version.model_dump()) - - request = setup_pb2.UpdateSetupRequest( - setup_id=valid_data.id, - name=valid_data.name, - owner_id=valid_data.owner_id or "", - current_setup_version=current_setup_version, - ) - response = await self.exec_grpc_query("UpdateSetup", request) - logger.debug("Setup '%s' query sent successfully", valid_data.name) - return response.success - - async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: - """Delete a setup by its unique identifier. - - Args: - setup_dict: Dictionary with the setup 'setup_id'. - - Returns: - bool: Success status of deletion. - - Raises: - ValidationError: If the setup setup_id is missing. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Setup Deletion"): - setup_id = setup_dict.get("setup_id") - if not setup_id: - msg = "Setup name is required for deletion" - raise ValidationError(msg) - request = setup_pb2.DeleteSetupRequest(setup_id=setup_id) - response = await self.exec_grpc_query("DeleteSetup", request) - logger.debug("Setup '%s' query sent successfully", setup_id) - return response.success - - async def create_setup_version(self, setup_version_dict: dict[str, Any]) -> str: - """Create a new setup version. - - Args: - setup_version_dict: Dictionary with setup version details. - - Returns: - str: version of setup version creation. - - Raises: - ValidationError: If setup version data is invalid. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Setup Version Creation"): - valid_data = SetupVersionData.model_validate(setup_version_dict) - content_struct = Struct() - content_struct.update(valid_data.content) - request = setup_pb2.CreateSetupVersionRequest( - setup_id=valid_data.setup_id, - version=valid_data.version, - content=content_struct, - ) - logger.debug( - "Setup Version '%s' for setup '%s' query sent successfully", - valid_data.version, - valid_data.setup_id, - ) - return await self.exec_grpc_query("CreateSetupVersion", request) - - async def get_setup_version(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: - """Retrieve a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'setup_version_id'. - - Returns: - dict[str, Any]: Setup version details. - - Raises: - ValidationError: If the setup version id is missing. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Get Setup Version"): - setup_version_id = setup_version_dict.get("setup_version_id") - if not setup_version_id: - msg = "Setup version id is required" - raise ValidationError(msg) - request = setup_pb2.GetSetupVersionRequest(setup_version_id=setup_version_id) - response = await self.exec_grpc_query("GetSetupVersion", request) - return SetupVersionData(**proto_to_dict(response.setup_version)) - - async def search_setup_versions(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: - """Search for setup versions based on filters. - - Args: - setup_version_dict: Dictionary with optional 'name' and 'version' filters. - - Returns: - list[dict[str, Any]]: A list of matching setup version details. - - Raises: - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - ValidationError: If both name and version are not provided. - """ - async with self.handle_grpc_errors("Search Setup Versions"): - if "name" not in setup_version_dict and "version" not in setup_version_dict: - msg = "Either name or version must be provided" - raise ValidationError(msg) - request = setup_pb2.SearchSetupVersionsRequest( - setup_id=setup_version_dict.get("setup_id", ""), - version=setup_version_dict.get("version", ""), - ) - response = await self.exec_grpc_query("SearchSetupVersions", request) - return [SetupVersionData(**proto_to_dict(sv)) for sv in response.setup_versions] - - async def update_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Update an existing setup version. - - Args: - setup_version_dict: Dictionary with setup version update details. - - Returns: - bool: Success status of the update operation. - - Raises: - ValidationError: If setup version data is invalid. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Setup Version Update"): - valid_data = SetupVersionData.model_validate(setup_version_dict) - content_struct = Struct() - content_struct.update(valid_data.content) - request = setup_pb2.UpdateSetupVersionRequest( - setup_version_id=valid_data.id, - version=valid_data.version, - content=content_struct, - ) - response = await self.exec_grpc_query("UpdateSetupVersion", request) - logger.debug( - "Setup Version '%s' for setup '%s' query sent successfully", - valid_data.id, - valid_data.setup_id, - ) - return response.success - - async def delete_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Delete a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'name'. - - Returns: - bool: Success status of version deletion. - - Raises: - ValidationError: If the setup version name is missing. - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("Setup Version Deletion"): - setup_version_id = setup_version_dict.get("setup_version_id") - if not setup_version_id: - msg = "Setup version id is required for deletion" - raise ValidationError(msg) - request = setup_pb2.DeleteSetupVersionRequest(setup_version_id=setup_version_id) - response = await self.exec_grpc_query("DeleteSetupVersion", request) - logger.debug("Setup Version '%s' query sent successfully", setup_version_id) - return response.success - - async def list_setups(self, list_dict: dict[str, Any]) -> dict[str, Any]: - """List setups with optional filtering and pagination. - - Args: - list_dict: Dictionary with optional filters: - - organisation_id: Filter by organisation - - owner_id: Filter by owner - - limit: Maximum number of results - - offset: Number of results to skip - - Returns: - dict[str, Any]: Dictionary with 'setups' list and 'total_count'. - - Raises: - ServerError: If gRPC operation fails. - SetupServiceError: For any unexpected internal error. - """ - async with self.handle_grpc_errors("List Setups"): - request = setup_pb2.ListSetupsRequest( - organisation_id=list_dict.get("organisation_id", ""), - owner_id=list_dict.get("owner_id", ""), - limit=list_dict.get("limit", 0), - offset=list_dict.get("offset", 0), - ) - response = await self.exec_grpc_query("ListSetups", request) - return { - "setups": [proto_to_dict(setup) for setup in response.setups], - "total_count": response.total_count, - } diff --git a/src/digitalkin/services/setup/setup_default.py b/src/digitalkin/services/setup/setup_default.py new file mode 100644 index 00000000..e4484312 --- /dev/null +++ b/src/digitalkin/services/setup/setup_default.py @@ -0,0 +1,114 @@ +"""This module contains the abstract base class for setup strategies.""" + +import secrets +import string +from typing import Any + +from pydantic import ValidationError + +from digitalkin.exception.setup import SetupServiceError +from digitalkin.logger import logger +from digitalkin.models.services.setup import SetupData, SetupVersionData +from digitalkin.services.setup.setup_strategy import SetupStrategy + + +class DefaultSetup(SetupStrategy): + """Abstract base class for setup strategies.""" + + setups: dict[str, SetupData] + setup_versions: dict[str, dict[str, SetupVersionData]] + + def __init__( + self, mission_id: str | None = None, setup_id: str | None = None, setup_version_id: str | None = None + ) -> None: + """Initialize the default setup strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version this strategy is associated with + """ + super().__init__(mission_id, setup_id, setup_version_id) + self.setups = {} + self.setup_versions = {} + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def create(self, setup_dict: dict[str, Any]) -> str: + """Create a setup in local storage. + + Returns: + The setup ID. + """ + try: + valid_data = SetupData.model_validate(setup_dict["data"]) # Revalidates instance + except ValidationError: + logger.exception("Validation failed for model SetupData") + return "" + + setup_id = setup_dict.get( + "setup_id", "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) + ) + valid_data.id = setup_id + self.setups[setup_id] = valid_data + logger.debug("CREATE SETUP DATA %s:%s successful", setup_id, valid_data) + return setup_id + + async def get(self, setup_dict: dict[str, Any]) -> SetupData: + """Retrieve a setup by ID from local storage. + + Returns: + The setup data. + + Raises: + SetupServiceError: If setup not found. + """ + logger.debug("GET setup_id = %s", setup_dict["setup_id"]) + if setup_dict["setup_id"] not in self.setups: + msg = f"GET setup_id = {setup_dict['setup_id']}: setup_id DOESN'T EXIST" + logger.error(msg) + raise SetupServiceError(msg) + return self.setups[setup_dict["setup_id"]] + + async def update(self, setup_dict: dict[str, Any]) -> bool: + """Update a setup in local storage. + + Returns: + The updated setup data. + """ + if setup_dict["setup_id"] not in self.setups: + logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_dict["setup_id"]) + return False + + try: + valid_data = SetupData.model_validate(setup_dict["data"]) # Revalidates instance + except ValidationError: + logger.exception("Validation failed for model SetupData") + return False + + self.setups[setup_dict["update_id"]] = valid_data + return True + + async def delete(self, setup_dict: dict[str, Any]) -> bool: + """Delete a setup from local storage. + + Returns: + True if setup was deleted. + """ + if setup_dict["setup_id"] not in self.setups: + logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_dict["setup_id"]) + return False + del self.setups[setup_dict["setup_id"]] + return True + + async def list(self, list_dict: dict[str, Any]) -> dict[str, Any]: + """List setups with optional pagination. + + Returns: + List of setup data. + """ + setups = list(self.setups.values()) + offset = list_dict.get("offset", 0) + limit = list_dict.get("limit", 0) + setups = setups[offset : offset + limit] if limit > 0 else setups[offset:] + return {"setups": [s.model_dump() for s in setups], "total_count": len(self.setups)} diff --git a/src/digitalkin/services/setup/setup_grpc.py b/src/digitalkin/services/setup/setup_grpc.py new file mode 100644 index 00000000..319ed554 --- /dev/null +++ b/src/digitalkin/services/setup/setup_grpc.py @@ -0,0 +1,165 @@ +"""Digital Kin Setup Service gRPC Client.""" + +from typing import Any + +from agentic_mesh_protocol.pagination.v1.pagination_pb2 import PaginationRequest +from agentic_mesh_protocol.setup.v1 import ( + setup_dto_pb2, + setup_messages_pb2, + setup_service_pb2_grpc, +) +from google.protobuf import json_format +from pydantic import ValidationError + +from digitalkin.exception.setup import SetupServiceError +from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper +from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin +from digitalkin.logger import logger +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.setup import SetupData +from digitalkin.services.setup.setup_strategy import SetupStrategy + + +class GrpcSetup(SetupStrategy, GrpcClientWrapper, GrpcErrorHandlerMixin): + """This class implements the gRPC setup service.""" + + def __init__( + self, + mission_id: str | None = None, + setup_id: str | None = None, + setup_version_id: str | None = None, + client_config: ClientConfig = None, + config: dict[str, Any] | None = None, + ) -> None: + """Initialize the gRPC setup strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version this strategy is associated with + client_config: Configuration for the gRPC client connection + config: Configuration for the filesystem strategy + """ + super().__init__(mission_id, setup_id, setup_version_id, config) + self.service_name = "SetupService" + channel = self._init_channel(client_config) + self.stub = setup_service_pb2_grpc.SetupServiceStub(channel) + logger.debug("Channel client 'Setup' initialized successfully") + + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # + + def __post_init__(self, config: ClientConfig) -> None: + """Init the channel from a config file. + + Need to be call if the user register a gRPC channel. + """ + channel = self._init_channel(config) + self.stub = setup_service_pb2_grpc.SetupServiceStub(channel) + logger.debug("Channel client 'setup' initialized successfully") + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def create(self, setup_dict: dict[str, Any]) -> str: + """Create a setup via gRPC. + + Returns: + The setup ID. + """ + async with self.handle_grpc_errors("CreateSetup", SetupServiceError): + valid_data = SetupData.model_validate(setup_dict) + + request = setup_dto_pb2.CreateSetupRequest( + name=valid_data.name, + organization_id=valid_data.organization_id, + owner_id=valid_data.owner_id, + module_id=valid_data.module_id, + current_setup_version=setup_messages_pb2.SetupVersion(**valid_data.current_setup_version.model_dump()), + ) + response = await self.exec_grpc_query("CreateSetup", request) + logger.debug("Setup '%s' query sent successfully", valid_data.name) + return response + + async def get(self, setup_dict: dict[str, Any]) -> SetupData: + """Retrieve a setup via gRPC. + + Returns: + The setup data. + + Raises: + ValidationError: If validation fails. + """ + async with self.handle_grpc_errors("GetSetup", SetupServiceError): + if "setup_id" not in setup_dict: + msg = "Setup name is required" + raise ValidationError(msg) + request = setup_dto_pb2.GetSetupRequest( + setup_id=setup_dict["setup_id"], + version=setup_dict.get("version", ""), + ) + response = await self.exec_grpc_query("GetSetup", request) + response_data = json_format.MessageToDict(response, preserving_proto_field_name=True) + return SetupData(**response_data["result"]["setup"]) + + async def update(self, setup_dict: dict[str, Any]) -> bool: + """Update a setup via gRPC. + + Returns: + The updated setup data. + """ + current_setup_version = None + + async with self.handle_grpc_errors("SetupUpdate", SetupServiceError): + valid_data = SetupData.model_validate(setup_dict) + + if valid_data.current_setup_version is not None: + current_setup_version = setup_messages_pb2.SetupVersion(**valid_data.current_setup_version.model_dump()) + + request = setup_dto_pb2.UpdateSetupRequest( + setup_id=valid_data.id, + name=valid_data.name, + owner_id=valid_data.owner_id or "", + current_setup_version=current_setup_version, + ) + response = await self.exec_grpc_query("UpdateSetup", request) + logger.debug("Setup '%s' query sent successfully", valid_data.name) + return response.result.success + + async def delete(self, setup_dict: dict[str, Any]) -> bool: + """Delete a setup via gRPC. + + Returns: + The setup data. + + Raises: + ValidationError: If validation fails. + """ + async with self.handle_grpc_errors("SetupDeletion", SetupServiceError): + setup_id = setup_dict.get("setup_id") + if not setup_id: + msg = "Setup name is required for deletion" + raise ValidationError(msg) + request = setup_dto_pb2.DeleteSetupRequest(setup_id=setup_id) + response = await self.exec_grpc_query("DeleteSetup", request) + logger.debug("Setup '%s' query sent successfully", setup_id) + return response.result.success + + async def list(self, list_dict: dict[str, Any]) -> dict[str, Any]: + """List setups with pagination via gRPC. + + Returns: + True if setup was deleted. + """ + async with self.handle_grpc_errors("ListSetups", SetupServiceError): + request = setup_dto_pb2.ListSetupsRequest( + organization_id=list_dict.get("organization_id", ""), + owner_id=list_dict.get("owner_id", ""), + pagination=PaginationRequest(limit=list_dict.get("limit", 0), offset=list_dict.get("offset", 0)), + ) + response = await self.exec_grpc_query("ListSetups", request) + return { + "setups": [ + json_format.MessageToDict(setup_result.setup, preserving_proto_field_name=True) + for setup_result in response.result + ], + "total_count": response.bulk.total_process, + } diff --git a/src/digitalkin/services/setup/setup_strategy.py b/src/digitalkin/services/setup/setup_strategy.py index 27985d06..929a0066 100644 --- a/src/digitalkin/services/setup/setup_strategy.py +++ b/src/digitalkin/services/setup/setup_strategy.py @@ -1,48 +1,35 @@ """This module contains the abstract base class for setup strategies.""" -import datetime from abc import ABC, abstractmethod from typing import Any -from pydantic import BaseModel +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.services.setup import SetupData -class SetupServiceError(Exception): - """Base exception for Setup service errors.""" - - -class SetupVersionData(BaseModel): - """Pydantic model for SetupVersion data validation.""" - - id: str - setup_id: str - version: str - content: dict[str, Any] - creation_date: datetime.datetime - - -class SetupData(BaseModel): - """Pydantic model for Setup data validation.""" - - id: str - name: str - organisation_id: str - owner_id: str - module_id: str - current_setup_version: SetupVersionData - - -class SetupStrategy(ABC): +class SetupStrategy(BaseStrategy, ABC): """Abstract base class for setup strategies.""" - def __init__(self) -> None: - """Initialize the setup strategy.""" + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + config: dict[str, Any] | None = None, + ) -> None: + """Initialize the strategy.""" + super().__init__(mission_id, setup_id, setup_version_id) + self.config = config + + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Lifecycle hook for post-initialization. Subclasses override with specific params.""" + # ═══════════════════════════════ Overriding Merthods ════════════════════════════════ # + @abstractmethod - async def create_setup(self, setup_dict: dict[str, Any]) -> str: + async def create(self, setup_dict: dict[str, Any]) -> str: """Create a new setup with comprehensive validation. Args: @@ -55,9 +42,10 @@ async def create_setup(self, setup_dict: dict[str, Any]) -> str: ValidationError: If setup data is invalid. GrpcOperationError: If gRPC operation fails. """ + return await super().create() @abstractmethod - async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: + async def get(self, setup_dict: dict[str, Any]) -> SetupData: """Retrieve a setup by its unique identifier. Args: @@ -66,95 +54,66 @@ async def get_setup(self, setup_dict: dict[str, Any]) -> SetupData: Returns: Dict[str, Any]: Setup details including optional setup version. """ + return await super().get() @abstractmethod - async def update_setup(self, setup_dict: dict[str, Any]) -> bool: - """Update an existing setup. - - Args: - setup_dict: Dictionary with setup update details. - - Returns: - bool: Success status of the update operation. - """ - - @abstractmethod - async def delete_setup(self, setup_dict: dict[str, Any]) -> bool: - """Delete a setup by its unique identifier. - - Args: - setup_dict: Dictionary with the setup 'name'. - - Returns: - bool: Success status of deletion. - """ - - @abstractmethod - async def create_setup_version(self, setup_version_dict: dict[str, Any]) -> str: - """Create a new setup version. + async def list(self, list_dict: dict[str, Any]) -> dict[str, Any]: + """List setups with optional filtering and pagination. Args: - setup_version_dict: Dictionary with setup version details. + list_dict: Dictionary with optional filters: + - organization_id: Filter by organization + - owner_id: Filter by owner + - limit: Maximum number of results + - offset: Number of results to skip Returns: - str: name of setup version creation. - """ + dict[str, Any]: Dictionary with 'setups' list and 'total_count'. - @abstractmethod - async def get_setup_version(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: - """Retrieve a setup version by its unique identifier. - - Args: - setup_version_dict: Dictionary with the setup version 'name'. - - Returns: - Dict[str, Any]: Setup version details. + Raises: + ServerError: If gRPC operation fails. + SetupServiceError: For any unexpected internal error. """ + return await super().list() @abstractmethod - async def search_setup_versions(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: - """Search for setup versions based on filters. + async def update(self, setup_dict: dict[str, Any]) -> bool: + """Update an existing setup. Args: - setup_version_dict: Dictionary with optional 'name' and 'version' filters. + setup_dict: Dictionary with setup update details. Returns: - List[Dict[str, Any]]: A list of matching setup version details. + bool: Success status of the update operation. """ + return await super().update() @abstractmethod - async def update_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Update an existing setup version. + async def delete(self, setup_dict: dict[str, Any]) -> bool: + """Delete a setup by its unique identifier. Args: - setup_version_dict: Dictionary with setup version update details. + setup_dict: Dictionary with the setup 'name'. Returns: - bool: Success status of the update operation. + bool: Success status of deletion. """ + return await super().delete() - @abstractmethod - async def delete_setup_version(self, setup_version_dict: dict[str, Any]) -> bool: - """Delete a setup version by its unique identifier. + # ════════════════════════════ Unimplemented Methods ═════════════════════════════ # - Args: - setup_version_dict: Dictionary with the setup version 'name'. + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. Returns: - bool: Success status of version deletion. + NotImplementedError from base class. """ + return await super().search(args, kwargs) - @abstractmethod - async def list_setups(self, list_dict: dict[str, Any]) -> dict[str, Any]: - """List setups with optional filtering and pagination. - - Args: - list_dict: Dictionary with optional filters: - - organisation_id: Filter by organisation - - owner_id: Filter by owner - - limit: Maximum number of results - - offset: Number of results to skip + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. Returns: - dict[str, Any]: Dictionary with 'setups' list and 'total_count'. + NotImplementedError from base class. """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/setup/version/__init__.py b/src/digitalkin/services/setup/version/__init__.py new file mode 100644 index 00000000..e6f18901 --- /dev/null +++ b/src/digitalkin/services/setup/version/__init__.py @@ -0,0 +1 @@ +"""Setup version service strategies.""" diff --git a/src/digitalkin/services/setup/version/setup_version_default.py b/src/digitalkin/services/setup/version/setup_version_default.py new file mode 100644 index 00000000..5d93e800 --- /dev/null +++ b/src/digitalkin/services/setup/version/setup_version_default.py @@ -0,0 +1,126 @@ +"""This module contains the abstract base class for setup strategies.""" + +from typing import Any + +from pydantic import ValidationError + +from digitalkin.exception.setup import SetupVersionServiceError +from digitalkin.logger import logger +from digitalkin.models.services.setup import SetupData, SetupVersionData +from digitalkin.services.setup.version.setup_version_strategy import SetupVersionStrategy + + +class DefaultSetupVersion(SetupVersionStrategy): + """Abstract base class for setup strategies.""" + + setups: dict[str, SetupData] + setup_versions: dict[str, dict[str, SetupVersionData]] + + def __init__(self, mission_id: str, setup_id: str, setup_version_id: str) -> None: + """Initialize the default setup strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version this strategy is associated with + """ + super().__init__(mission_id, setup_id, setup_version_id) + self.setups = {} + self.setup_versions = {} + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def create(self, setup_version_dict: dict[str, Any]) -> str: + """Create a setup version in local storage. + + Returns: + The setup version ID. + + Raises: + SetupVersionServiceError: If version already exists. + """ + try: + valid_data = SetupVersionData.model_validate(setup_version_dict["data"]) # Revalidates instance + except ValidationError: + msg = "Validation failed for model SetupVersionData" + logger.exception(msg) + raise SetupVersionServiceError(msg) + + if setup_version_dict["setup_id"] not in self.setup_versions: + self.setup_versions[setup_version_dict["setup_id"]] = {} + self.setup_versions[setup_version_dict["setup_id"]][valid_data.version] = valid_data + logger.debug("CREATE SETUP VERSION DATA %s:%s successful", setup_version_dict["setup_id"], valid_data) + return valid_data.version + + async def get(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: + """Retrieve a setup version from local storage. + + Returns: + The setup version data. + + Raises: + SetupVersionServiceError: If version not found. + """ + logger.debug("GET setup_id = %s: version = %s", setup_version_dict["setup_id"], setup_version_dict["version"]) + if setup_version_dict["setup_id"] not in self.setup_versions: + msg = f"GET setup_id = {setup_version_dict['setup_id']}: setup_id DOESN'T EXIST" + logger.error(msg) + raise SetupVersionServiceError(msg) + + return self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] + + async def search(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: + """Search setup versions by query string. + + Returns: + The updated setup version. + + Raises: + SetupVersionServiceError: If version not found. + """ + if setup_version_dict["setup_id"] not in self.setup_versions: + msg = f"GET setup_id = {setup_version_dict['setup_id']}: setup_id DOESN'T EXIST" + logger.error(msg) + raise SetupVersionServiceError(msg) + + return [ + value + for value in self.setup_versions[setup_version_dict["setup_id"]].values() + if setup_version_dict["query_versions"] in value.version + ] + + async def update(self, setup_version_dict: dict[str, Any]) -> bool: + """Update a setup version in local storage. + + Returns: + True if version was deleted. + """ + if setup_version_dict["setup_id"] not in self.setup_versions: + logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) + return False + + if setup_version_dict["version"] not in self.setup_versions[setup_version_dict["setup_id"]]: + logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) + return False + + try: + valid_data = SetupVersionData.model_validate(setup_version_dict["data"]) + except ValidationError: + logger.exception("Validation failed for model SetupVersionData") + return False + + self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] = valid_data + return True + + async def delete(self, setup_version_dict: dict[str, Any]) -> bool: + """Delete a setup version from local storage. + + Returns: + The setup version data dict. + """ + if setup_version_dict["setup_id"] not in self.setup_versions: + logger.debug("UPDATE setup_id = %s: setup_id DOESN'T EXIST", setup_version_dict["setup_id"]) + return False + + del self.setup_versions[setup_version_dict["setup_id"]][setup_version_dict["version"]] + return True diff --git a/src/digitalkin/services/setup/version/setup_version_grpc.py b/src/digitalkin/services/setup/version/setup_version_grpc.py new file mode 100644 index 00000000..c359b833 --- /dev/null +++ b/src/digitalkin/services/setup/version/setup_version_grpc.py @@ -0,0 +1,162 @@ +"""Digital Kin Setup Service gRPC Client.""" + +from typing import Any + +from agentic_mesh_protocol.setup.v1 import setup_version_dto_pb2, setup_version_service_pb2_grpc +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Struct +from pydantic import ValidationError + +from digitalkin.exception.setup import SetupVersionServiceError +from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper +from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin +from digitalkin.logger import logger +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.setup import SetupVersionData +from digitalkin.services.setup.version.setup_version_strategy import SetupVersionStrategy + + +class GrpcSetupVersion(SetupVersionStrategy, GrpcClientWrapper, GrpcErrorHandlerMixin): + """This class implements the gRPC setup service.""" + + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + client_config: ClientConfig, + config: dict[str, Any] | None = None, + ) -> None: + """Initialize the gRPC setup version strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version this strategy is associated with + client_config: Configuration for the gRPC client connection + config: Configuration for the filesystem strategy + """ + super().__init__(mission_id, setup_id, setup_version_id, config) + self.service_name = "SetupVersionService" + channel = self._init_channel(client_config) + self.stub = setup_version_service_pb2_grpc.SetupVersionServiceStub(channel) + logger.debug("Channel client 'SetupVersion' initialized successfully") + + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # + + def __post_init__(self, config: ClientConfig) -> None: + """Init the channel from a config file. + + Need to be call if the user register a gRPC channel. + """ + channel = self._init_channel(config) + self.stub = setup_version_service_pb2_grpc.SetupVersionServiceStub(channel) + logger.debug("Channel client 'setup' initialized successfully") + + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + async def create(self, setup_version_dict: dict[str, Any]) -> str: + """Create a setup version via gRPC. + + Returns: + The setup version ID. + """ + async with self.handle_grpc_errors("Setup Version Creation", SetupVersionServiceError): + valid_data = SetupVersionData.model_validate(setup_version_dict) + content_struct = Struct() + content_struct.update(valid_data.content) + request = setup_version_dto_pb2.CreateSetupVersionRequest( + setup_id=valid_data.setup_id, + version=valid_data.version, + content=content_struct, + ) + logger.debug( + "Setup Version '%s' for setup '%s' query sent successfully", + valid_data.version, + valid_data.setup_id, + ) + return await self.exec_grpc_query("CreateSetupVersion", request) + + async def get(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: + """Retrieve a setup version via gRPC. + + Returns: + The setup version data. + + Raises: + ValidationError: If validation fails. + """ + async with self.handle_grpc_errors("Get Setup Version"): + setup_version_id = setup_version_dict.get("setup_version_id") + if not setup_version_id: + msg = "Setup version id is required" + raise ValidationError(msg) + request = setup_version_dto_pb2.GetSetupVersionRequest(setup_version_id=setup_version_id) + response = await self.exec_grpc_query("GetSetupVersion", request) + return SetupVersionData( + **json_format.MessageToDict(response.result.version, preserving_proto_field_name=True) + ) + + async def search(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: + """Search setup versions via gRPC. + + Returns: + The updated setup version. + + Raises: + ValidationError: If validation fails. + """ + async with self.handle_grpc_errors("Search Setup Versions"): + if "name" not in setup_version_dict and "version" not in setup_version_dict: + msg = "Either name or version must be provided" + raise ValidationError(msg) + request = setup_version_dto_pb2.SearchSetupVersionsRequest( + setup_id=setup_version_dict.get("setup_id", ""), + version=setup_version_dict.get("version", ""), + ) + response = await self.exec_grpc_query("SearchSetupVersions", request) + return [ + SetupVersionData(**json_format.MessageToDict(sv_result.version, preserving_proto_field_name=True)) + for sv_result in response.result + ] + + async def update(self, setup_version_dict: dict[str, Any]) -> bool: + """Update a setup version via gRPC. + + Returns: + True if version was deleted. + """ + async with self.handle_grpc_errors("Setup Version Update"): + valid_data = SetupVersionData.model_validate(setup_version_dict) + content_struct = Struct() + content_struct.update(valid_data.content) + request = setup_version_dto_pb2.UpdateSetupVersionRequest( + setup_version_id=valid_data.id, + version=valid_data.version, + content=content_struct, + ) + response = await self.exec_grpc_query("UpdateSetupVersion", request) + logger.debug( + "Setup Version '%s' for setup '%s' query sent successfully", + valid_data.id, + valid_data.setup_id, + ) + return response.result.success + + async def delete(self, setup_version_dict: dict[str, Any]) -> bool: + """Delete a setup version via gRPC. + + Returns: + The setup version data dict. + + Raises: + ValidationError: If validation fails. + """ + async with self.handle_grpc_errors("Setup Version Deletion"): + setup_version_id = setup_version_dict.get("setup_version_id") + if not setup_version_id: + msg = "Setup version id is required for deletion" + raise ValidationError(msg) + request = setup_version_dto_pb2.DeleteSetupVersionRequest(setup_version_id=setup_version_id) + response = await self.exec_grpc_query("DeleteSetupVersion", request) + logger.debug("Setup Version '%s' query sent successfully", setup_version_id) + return response.result.success diff --git a/src/digitalkin/services/setup/version/setup_version_strategy.py b/src/digitalkin/services/setup/version/setup_version_strategy.py new file mode 100644 index 00000000..286836d4 --- /dev/null +++ b/src/digitalkin/services/setup/version/setup_version_strategy.py @@ -0,0 +1,107 @@ +"""This module contains the abstract base class for setup strategies.""" + +from abc import ABC, abstractmethod +from typing import Any + +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.services.setup import SetupVersionData + + +class SetupVersionStrategy(BaseStrategy, ABC): + """Abstract base class for setup strategies.""" + + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + config: dict[str, Any] | None = None, + ) -> None: + """Initialize the strategy.""" + super().__init__(mission_id, setup_id, setup_version_id) + self.config = config + + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # + + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the setup strategy.""" + + # ═════════════════════════════════ Overrinding Methods ═════════════════════════════════ # + + @abstractmethod + async def create(self, setup_version_dict: dict[str, Any]) -> str: + """Create a new setup version. + + Args: + setup_version_dict: Dictionary with setup version details. + + Returns: + str: name of setup version creation. + """ + return await super().create() + + @abstractmethod + async def get(self, setup_version_dict: dict[str, Any]) -> SetupVersionData: + """Retrieve a setup version by its unique identifier. + + Args: + setup_version_dict: Dictionary with the setup version 'name'. + + Returns: + Dict[str, Any]: Setup version details. + """ + return await super().get() + + @abstractmethod + async def search(self, setup_version_dict: dict[str, Any]) -> list[SetupVersionData]: + """Search for setup versions based on filters. + + Args: + setup_version_dict: Dictionary with optional 'name' and 'version' filters. + + Returns: + List[Dict[str, Any]]: A list of matching setup version details. + """ + return await super().search() + + @abstractmethod + async def update(self, setup_version_dict: dict[str, Any]) -> bool: + """Update an existing setup version. + + Args: + setup_version_dict: Dictionary with setup version update details. + + Returns: + bool: Success status of the update operation. + """ + return await super().update() + + @abstractmethod + async def delete(self, setup_version_dict: dict[str, Any]) -> bool: + """Delete a setup version by its unique identifier. + + Args: + setup_version_dict: Dictionary with the setup version 'name'. + + Returns: + bool: Success status of version deletion. + """ + return await super().delete() + + # ════════════════════════════ Unimplemented Methods ═════════════════════════════ # + + async def list(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().list(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/snapshot/__init__.py b/src/digitalkin/services/snapshot/__init__.py index 51ea1916..8f3dab2e 100644 --- a/src/digitalkin/services/snapshot/__init__.py +++ b/src/digitalkin/services/snapshot/__init__.py @@ -1,6 +1,6 @@ """This module is responsible for handling the snapshot service.""" -from digitalkin.services.snapshot.default_snapshot import DefaultSnapshot +from digitalkin.services.snapshot.snapshot_default import DefaultSnapshot from digitalkin.services.snapshot.snapshot_strategy import SnapshotStrategy __all__ = ["DefaultSnapshot", "SnapshotStrategy"] diff --git a/src/digitalkin/services/snapshot/default_snapshot.py b/src/digitalkin/services/snapshot/default_snapshot.py deleted file mode 100644 index cc55f20e..00000000 --- a/src/digitalkin/services/snapshot/default_snapshot.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Default snapshot.""" - -from typing import Any - -from digitalkin.services.snapshot.snapshot_strategy import SnapshotStrategy - - -class DefaultSnapshot(SnapshotStrategy): - """Default snapshot strategy.""" - - def create(self, data: dict[str, Any]) -> str: # noqa: ARG002, PLR6301 - """Create a new snapshot in the file system. - - Returns: - str: The ID of the new snapshot - """ - return "1" - - def get(self, data: dict[str, Any]) -> None: - """Get snapshots from the file system.""" - - def update(self, data: dict[str, Any]) -> int: # noqa: ARG002, PLR6301 - """Update snapshots in the file system. - - Returns: - int: The number of snapshots updated - """ - return 1 - - def delete(self, data: dict[str, Any]) -> int: # noqa: ARG002, PLR6301 - """Delete snapshots from the file system. - - Returns: - int: The number of snapshots deleted - """ - return 1 - - def get_all(self) -> None: - """Get all snapshots from the file system.""" diff --git a/src/digitalkin/services/snapshot/snapshot_default.py b/src/digitalkin/services/snapshot/snapshot_default.py new file mode 100644 index 00000000..67e5d23e --- /dev/null +++ b/src/digitalkin/services/snapshot/snapshot_default.py @@ -0,0 +1,41 @@ +"""Default snapshot.""" + +from typing import Any + +from digitalkin.services.snapshot.snapshot_strategy import SnapshotStrategy + + +class DefaultSnapshot(SnapshotStrategy): + """Default snapshot strategy.""" + + def create(self, _data: dict[str, Any]) -> str: + """Create a snapshot (stub). + + Returns: + The snapshot ID. + """ + return "1" + + def list(self, _data: dict[str, Any]) -> None: + """List snapshots (stub).""" + return + + def update(self, _data: dict[str, Any]) -> int: + """Update a snapshot (stub). + + Returns: + Update count. + """ + return 1 + + def delete(self, _data: dict[str, Any]) -> int: + """Delete a snapshot (stub). + + Returns: + Deletion count. + """ + return 1 + + def get_all(self) -> None: + """Get all snapshots (stub).""" + return diff --git a/src/digitalkin/services/snapshot/snapshot_strategy.py b/src/digitalkin/services/snapshot/snapshot_strategy.py index 8edaee1a..37773ad8 100644 --- a/src/digitalkin/services/snapshot/snapshot_strategy.py +++ b/src/digitalkin/services/snapshot/snapshot_strategy.py @@ -3,28 +3,90 @@ from abc import ABC, abstractmethod from typing import Any -from digitalkin.services.base_strategy import BaseStrategy +from digitalkin.models.base_strategy import BaseStrategy class SnapshotStrategy(BaseStrategy, ABC): """Abstract base class for snapshot strategies.""" + # ════════════════════════════════ Overriding Methods ════════════════════════════════ # + @abstractmethod - def create(self, data: dict[str, Any]) -> str: - """Create a new snapshot in the file system.""" + async def create(self, data: dict[str, Any]) -> str: + """Create a new snapshot in the file system. + + Args: + data: A dictionary containing the data needed to create the snapshot + + Returns: + str: The ID of the new snapshot + """ + return await super().create() @abstractmethod - def get(self, data: dict[str, Any]) -> None: - """Get snapshots from the file system.""" + async def list(self, data: dict[str, Any]) -> None: + """Get snapshots from the file system. + + Args: + data: A dictionary containing the data needed to list the snapshots + + """ + return await super().list() @abstractmethod - def update(self, data: dict[str, Any]) -> int: - """Update snapshots in the file system.""" + async def update(self, data: dict[str, Any]) -> int: + """Update snapshots in the file system. + + Args: + data: A dictionary containing the data needed to update the snapshots + + Returns: + int: The number of snapshots updated + + """ + return await super().update() @abstractmethod - def delete(self, data: dict[str, Any]) -> int: - """Delete snapshots from the file system.""" + async def delete(self, data: dict[str, Any]) -> int: + """Delete snapshots from the file system. + + Args: + data: A dictionary containing the data needed to delete the snapshots + + Returns: + int: The number of snapshots deleted + + """ + return await super().delete() @abstractmethod - def get_all(self) -> None: + async def get_all(self) -> None: """Get all snapshots from the file system.""" + msg = "Get all snapshots is not implemented yet." + raise NotImplementedError(msg) + + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # + + async def get(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().get(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().search(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/storage/__init__.py b/src/digitalkin/services/storage/__init__.py index 4b9b4691..79b1634d 100644 --- a/src/digitalkin/services/storage/__init__.py +++ b/src/digitalkin/services/storage/__init__.py @@ -1,7 +1,7 @@ """This module is responsible for handling the storage service.""" -from digitalkin.services.storage.default_storage import DefaultStorage -from digitalkin.services.storage.grpc_storage import GrpcStorage +from digitalkin.services.storage.storage_default import DefaultStorage +from digitalkin.services.storage.storage_grpc import GrpcStorage from digitalkin.services.storage.storage_strategy import StorageStrategy __all__ = ["DefaultStorage", "GrpcStorage", "StorageStrategy"] diff --git a/src/digitalkin/services/storage/grpc_storage.py b/src/digitalkin/services/storage/grpc_storage.py deleted file mode 100644 index 815aff8f..00000000 --- a/src/digitalkin/services/storage/grpc_storage.py +++ /dev/null @@ -1,225 +0,0 @@ -"""This module implements the default storage strategy.""" - -from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2_grpc -from google.protobuf.struct_pb2 import Struct -from pydantic import BaseModel - -from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper -from digitalkin.logger import logger -from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.services.storage.storage_strategy import ( - DataType, - StorageRecord, - StorageServiceError, - StorageStrategy, -) -from digitalkin.utils.proto_utils import proto_to_dict - - -class GrpcStorage(StorageStrategy, GrpcClientWrapper): - """gRPC client implementation for the Storage service.""" - - service_name: str = "StorageService" - - def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageRecord: - """Convert a protobuf StorageRecord message into our Pydantic model. - - Uses direct field access for scalar fields and selective MessageToDict - only for the nested Struct payload, avoiding full-message deserialization. - - Args: - proto: gRPC StorageRecord - - Returns: - A fully validated StorageRecord. - """ - # Direct field access for scalars (avoids full MessageToDict overhead) - mission = proto.mission_id - coll = proto.collection - rid = proto.record_id - dtype = DataType[data_pb2.DataType.Name(proto.data_type)] - - # Selective deserialization: only the nested Struct payload - payload = proto_to_dict(proto.data) if proto.HasField("data") else {} - - # Timestamp conversion - creation_date = proto.creation_date.ToDatetime() if proto.HasField("creation_date") else None - update_date = proto.update_date.ToDatetime() if proto.HasField("update_date") else None - - validated = self._validate_data(coll, payload) - return StorageRecord( - mission_id=mission, - collection=coll, - record_id=rid, - data=validated, - data_type=dtype, - creation_date=creation_date, - update_date=update_date, - ) - - async def _store(self, record: StorageRecord) -> StorageRecord: - """Create a new record in the database. - - Parameters: - record: The record to store - - Returns: - StorageRecord: The corresponding record - - Raises: - StorageServiceError: If there is an error while storing the record - """ - logger.debug("debug:_store collection=%s id=%s", record.collection, record.record_id) - try: - data_struct = Struct() - data_struct.update(record.data.model_dump()) - req = data_pb2.StoreRecordRequest( - data=data_struct, - mission_id=record.mission_id, - collection=record.collection, - record_id=record.record_id, - data_type=record.data_type.name, - ) - resp = await self.exec_grpc_query("StoreRecord", req) - return self._build_record_from_proto(resp.stored_data) - except Exception as e: - logger.exception( - "gRPC StoreRecord failed for %s:%s", - record.collection, - record.record_id, - ) - raise StorageServiceError(str(e)) from e - - async def _read(self, collection: str, record_id: str) -> StorageRecord | None: - """Fetch a single document by collection + record_id. - - Returns: - StorageData: The record - """ - logger.debug("debug:_read collection=%s id=%s", collection, record_id) - try: - req = data_pb2.ReadRecordRequest( - mission_id=self.mission_id, - collection=collection, - record_id=record_id, - ) - resp = await self.exec_grpc_query("ReadRecord", req) - return self._build_record_from_proto(resp.stored_data) - except Exception: - logger.debug("gRPC ReadRecord failed for %s:%s", collection, record_id) - return None - - async def _update( - self, - collection: str, - record_id: str, - data: BaseModel, - ) -> StorageRecord | None: - """Overwrite a document via gRPC. - - Args: - collection: The unique name for the record type - record_id: The unique ID for the record - data: The validated data model - - Returns: - StorageRecord: The updated record - """ - logger.debug("debug:_update collection=%s id=%s", collection, record_id) - try: - struct = Struct() - struct.update(data.model_dump()) - req = data_pb2.UpdateRecordRequest( - data=struct, - mission_id=self.mission_id, - collection=collection, - record_id=record_id, - ) - resp = await self.exec_grpc_query("UpdateRecord", req) - return self._build_record_from_proto(resp.stored_data) - except Exception: - logger.warning("gRPC UpdateRecord failed for %s:%s", collection, record_id) - return None - - async def _remove(self, collection: str, record_id: str) -> bool: - """Delete a document via gRPC. - - Args: - collection: The unique name for the record type - record_id: The unique ID for the record - - Returns: - bool: True if the record was deleted, False otherwise - """ - logger.debug("debug:_remove collection=%s id=%s", collection, record_id) - try: - req = data_pb2.RemoveRecordRequest( - mission_id=self.mission_id, - collection=collection, - record_id=record_id, - ) - await self.exec_grpc_query("RemoveRecord", req) - except Exception: - logger.warning( - "gRPC RemoveRecord failed for %s:%s", - collection, - record_id, - ) - return False - return True - - async def _list(self, collection: str) -> list[StorageRecord]: - """List all documents in a collection via gRPC. - - Args: - collection: The unique name for the record type - - Returns: - list[StorageRecord]: A list of storage records - """ - logger.debug("debug:_list collection=%s", collection) - try: - req = data_pb2.ListRecordsRequest( - mission_id=self.mission_id, - collection=collection, - ) - resp = await self.exec_grpc_query("ListRecords", req) - return [self._build_record_from_proto(r) for r in resp.records] - except Exception: - logger.warning("gRPC ListRecords failed for %s", collection) - return [] - - async def _remove_collection(self, collection: str) -> bool: - """Delete an entire collection via gRPC. - - Args: - collection: The unique name for the record type - - Returns: - bool: True if the collection was deleted, False otherwise - """ - try: - req = data_pb2.RemoveCollectionRequest( - mission_id=self.mission_id, - collection=collection, - ) - await self.exec_grpc_query("RemoveCollection", req) - except Exception: - logger.warning("gRPC RemoveCollection failed for %s", collection) - return False - return True - - def __init__( - self, - mission_id: str, - setup_id: str, - setup_version_id: str, - config: dict[str, type[BaseModel]], - client_config: ClientConfig, - ) -> None: - """Initialize the storage.""" - super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=config) - - channel = self._init_channel(client_config) - self.stub = storage_service_pb2_grpc.StorageServiceStub(channel) - logger.debug("Channel client 'storage' initialized successfully") diff --git a/src/digitalkin/services/storage/default_storage.py b/src/digitalkin/services/storage/storage_default.py similarity index 62% rename from src/digitalkin/services/storage/default_storage.py rename to src/digitalkin/services/storage/storage_default.py index 5d630a79..f97c8958 100644 --- a/src/digitalkin/services/storage/default_storage.py +++ b/src/digitalkin/services/storage/storage_default.py @@ -5,13 +5,13 @@ import tempfile from pathlib import Path from typing import Any +from uuid import uuid4 from pydantic import BaseModel from digitalkin.logger import logger +from digitalkin.models.services.storage import DataType, StorageRecord from digitalkin.services.storage.storage_strategy import ( - DataType, - StorageRecord, StorageStrategy, ) @@ -23,8 +23,24 @@ class DefaultStorage(StorageStrategy): { ":": { ... StorageRecord fields ... }, """ + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + config: dict[str, type[BaseModel]], + storage_file_path: str = "local_storage", + ) -> None: + """Initialize the storage.""" + super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=config) + self.storage_file_path = f"{self.mission_id}_{storage_file_path}.json" + self.storage_file = Path(self.storage_file_path) + self.storage = self.__load_from_file() + + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # + @staticmethod - def _json_default(o: Any) -> str: + def __json_default(o: Any) -> str: """JSON serializer for non-standard types (datetime → ISO). Args: @@ -41,7 +57,7 @@ def _json_default(o: Any) -> str: msg = f"Type {o.__class__.__name__} not serializable" raise TypeError(msg) - def _load_from_file(self) -> dict[str, StorageRecord]: + def __load_from_file(self) -> dict[str, StorageRecord]: """Load storage data from the file. Returns: @@ -66,11 +82,9 @@ def _load_from_file(self) -> dict[str, StorageRecord]: collection=rd["collection"], record_id=rd["record_id"], data=data_model, - data_type=DataType[rd["data_type"]], - creation_date=datetime.datetime.fromisoformat(rd["creation_date"]) - if rd.get("creation_date") - else None, - update_date=datetime.datetime.fromisoformat(rd["update_date"]) if rd.get("update_date") else None, + data_type=rd["data_type"], + created_at=datetime.datetime.fromisoformat(rd["created_at"]) if rd.list("created_at") else None, + updated_at=datetime.datetime.fromisoformat(rd["updated_at"]) if rd.list("updated_at") else None, ) out[key] = rec except Exception: @@ -78,7 +92,7 @@ def _load_from_file(self) -> dict[str, StorageRecord]: return {} return out - def _save_to_file(self) -> None: + def __save_to_file(self) -> None: """Atomically write `self.storage` back to disk as JSON.""" self.storage_file.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( @@ -98,130 +112,110 @@ def _save_to_file(self) -> None: "record_id": record.record_id, "data_type": record.data_type.name, "data": record.data.model_dump(), - "creation_date": record.creation_date.isoformat() if record.creation_date else None, - "update_date": record.update_date.isoformat() if record.update_date else None, + "created_at": record.created_at.isoformat() if record.created_at else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None, } - json.dump(serial, temp, indent=2, default=self._json_default) + json.dump(serial, temp, indent=2, default=self.__json_default) temp.flush() Path(temp.name).replace(self.storage_file) except Exception: logger.exception("Unexpected error saving storage") - async def _store(self, record: StorageRecord) -> StorageRecord: - """Store a new record in the database and persist to file. - - Args: - record: The record to store - - Returns: - str: The ID of the new record + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # - Raises: - ValueError: If the record already exists - """ - key = f"{record.collection}:{record.record_id}" - if key in self.storage: - msg = f"Document {key!r} already exists" - raise ValueError(msg) - now = datetime.datetime.now(datetime.timezone.utc) - record.creation_date = now - record.update_date = now - self.storage[key] = record - self._save_to_file() - logger.debug("Created %s", key) - return record - - async def _read(self, collection: str, record_id: str) -> StorageRecord | None: - """Get records from the database. - - Args: - collection: The unique name to retrieve data for - record_id: The unique ID of the record - - Returns: - StorageRecord: The corresponding record - """ - key = f"{collection}:{record_id}" - return self.storage.get(key) - - async def _update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: - """Update records in the database and persist to file. - - Args: - collection: The unique name to retrieve data for - record_id: The unique ID of the record - data: The data to modify + async def update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: + """Update a record in local JSON storage. Returns: - StorageRecord: The modified record + The updated record, or None. """ key = f"{collection}:{record_id}" rec = self.storage.get(key) if not rec: return None rec.data = data - rec.update_date = datetime.datetime.now(datetime.timezone.utc) - self._save_to_file() + rec.updated_at = datetime.datetime.now(datetime.timezone.utc) + self.__save_to_file() logger.debug("Modified %s", key) return rec - async def _remove(self, collection: str, record_id: str) -> bool: - """Delete records from the database and update file. - - Args: - collection: The unique name to retrieve data for - record_id: The unique ID of the record + async def delete(self, collection: str, record_id: str) -> bool: + """Delete a record from local JSON storage. Returns: - bool: True if the record was removed, False otherwise + True if record was deleted. """ key = f"{collection}:{record_id}" if key not in self.storage: return False del self.storage[key] - self._save_to_file() + self.__save_to_file() logger.debug("Removed %s", key) return True - async def _list(self, collection: str) -> list[StorageRecord]: - """Implements StorageStrategy._list. + async def get(self, collection: str, record_id: str) -> StorageRecord | None: + """Retrieve a record by collection and ID. - Args: - collection: The unique name to retrieve data for + Returns: + List of records. + + + Returns: + The record, or None. + """ + key = f"{collection}:{record_id}" + return self.storage.get(key) + + async def list(self, collection: str) -> list[StorageRecord]: + """List all records in a collection. Returns: - A list of storage records + True if collection was deleted. """ prefix = f"{collection}:" return [r for k, r in self.storage.items() if k.startswith(prefix)] - async def _remove_collection(self, collection: str) -> bool: - """Implements StorageStrategy._remove_collection. - - Args: - collection: The unique name to retrieve data for + async def delete_collection(self, collection: str) -> bool: + """Delete all records in a collection. Returns: - bool: True if the collection was removed, False otherwise + True after deletion. """ prefix = f"{collection}:" to_delete = [k for k in self.storage if k.startswith(prefix)] for k in to_delete: del self.storage[k] - self._save_to_file() + self.__save_to_file() logger.debug("Removed collection %s (%d docs)", collection, len(to_delete)) return True - def __init__( - self, - mission_id: str, - setup_id: str, - setup_version_id: str, - config: dict[str, type[BaseModel]], - storage_file_path: str = "local_storage", - ) -> None: - """Initialize the storage.""" - super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=config) - self.storage_file_path = f"{self.mission_id}_{storage_file_path}.json" - self.storage_file = Path(self.storage_file_path) - self.storage = self._load_from_file() + async def create( + self, collection: str, record_id: str | None, data: BaseModel, data_type: DataType = DataType.OUTPUT + ) -> StorageRecord: + """Create a record in local JSON storage. + + Returns: + The created record. + + Raises: + TypeError: If invalid data type. + ValueError: If document already exists. + """ + if not isinstance(data_type, DataType): + msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}" + raise TypeError(msg) + record_id = record_id or uuid4().hex + validated_data = self._validate_data(collection, {**data, "mission_id": self.mission_id}) + record = self._create_storage_record(collection, record_id, validated_data, data_type) + + key = f"{record.collection}:{record.record_id}" + if key in self.storage: + msg = f"Document {key!r} already exists" + raise ValueError(msg) + now = datetime.datetime.now(datetime.timezone.utc) + record.created_at = now + record.updated_at = now + self.storage[key] = record + self.__save_to_file() + logger.debug("Created %s", key) + return record diff --git a/src/digitalkin/services/storage/storage_grpc.py b/src/digitalkin/services/storage/storage_grpc.py new file mode 100644 index 00000000..cd5a4473 --- /dev/null +++ b/src/digitalkin/services/storage/storage_grpc.py @@ -0,0 +1,179 @@ +"""This module implements the default storage strategy.""" + +from typing import Any +from uuid import uuid4 + +from agentic_mesh_protocol.storage.v1 import storage_dto_pb2, storage_messages_pb2, storage_service_pb2_grpc +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Struct +from pydantic import BaseModel + +from digitalkin.exception.storage import StorageServiceError +from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper +from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin +from digitalkin.logger import logger +from digitalkin.models.grpc_servers.models import ClientConfig +from digitalkin.models.services.storage import DataType, StorageRecord +from digitalkin.services.storage.storage_strategy import ( + StorageStrategy, +) + + +class GrpcStorage(StorageStrategy, GrpcClientWrapper, GrpcErrorHandlerMixin): + """This class implements the default storage strategy.""" + + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + config: dict[str, type[BaseModel]], + client_config: ClientConfig, + **_kwargs: Any, + ) -> None: + """Initialize the storage.""" + super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=config) + + channel = self._init_channel(client_config) + self.stub = storage_service_pb2_grpc.StorageServiceStub(channel) + logger.debug("Channel client 'storage' initialized successfully") + + def _build_record_from_proto(self, proto: storage_messages_pb2.StorageRecord) -> StorageRecord: + """Convert a protobuf StorageRecord message into our Pydantic model. + + Args: + proto: gRPC StorageRecord + + Returns: + A fully validated StorageRecord. + """ + raw = json_format.MessageToDict( + proto, + preserving_proto_field_name=True, + always_print_fields_with_no_presence=True, + ) + mission = raw["mission_id"] + coll = raw["collection"] + rid = raw["record_id"] + dtype = raw["data_type"] + payload = raw.get("data", {}) + + validated = self._validate_data(coll, payload) + return StorageRecord( + mission_id=mission, + collection=coll, + record_id=rid, + data=validated, + data_type=dtype, + created_at=raw.get("created_at"), + updated_at=raw.get("updated_at"), + ) + + # ════════════════════════════════ Public Method ═════════════════════════════════ # + + async def update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: + """Update a record via gRPC. + + Returns: + The updated record. + """ + data = self._validate_data(collection, {**data, "mission_id": self.mission_id}) + async with self.handle_grpc_errors("UpdateRecord", StorageServiceError): + struct = Struct() + struct.update(data.model_dump()) + req = storage_dto_pb2.UpdateRecordRequest( + data=struct, + mission_id=self.mission_id, + collection=collection, + record_id=record_id, + ) + resp = await self.exec_grpc_query("UpdateRecord", req) + return self._build_record_from_proto(resp.result.record) + + async def delete(self, collection: str, record_id: str) -> bool: + """Delete a record via gRPC. + + Returns: + True if record was deleted. + """ + async with self.handle_grpc_errors("DeleteRecord", StorageServiceError): + req = storage_dto_pb2.DeleteRecordRequest( + mission_id=self.mission_id, + collection=collection, + record_id=record_id, + ) + response = await self.exec_grpc_query("DeleteRecord", req) + logger.debug("Delete '%s' query sent successfully", self.mission_id) + return response.result.success + + async def get(self, collection: str, record_id: str) -> StorageRecord | None: + """Retrieve a record via gRPC. + + Returns: + The record, or None. + """ + async with self.handle_grpc_errors("GetRecord", StorageServiceError): + req = storage_dto_pb2.GetRecordRequest( + mission_id=self.mission_id, + collection=collection, + record_id=record_id, + ) + resp = await self.exec_grpc_query("GetRecord", req) + return self._build_record_from_proto(resp.result.record) + + async def list(self, collection: str) -> list[StorageRecord]: + """List all records in a collection via gRPC. + + Returns: + List of records. + """ + async with self.handle_grpc_errors("ListRecords", StorageServiceError): + req = storage_dto_pb2.ListRecordsRequest( + mission_id=self.mission_id, + collection=collection, + ) + resp = await self.exec_grpc_query("ListRecords", req) + return [self._build_record_from_proto(r.record) for r in resp.result] + + async def create( + self, collection: str, record_id: str | None, data: BaseModel, data_type: DataType = DataType.OUTPUT + ) -> StorageRecord: + """Create a record via gRPC. + + Returns: + The created record. + + Raises: + TypeError: If invalid data type. + """ + if not isinstance(data_type, DataType): + msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}" + raise TypeError(msg) + validated_data = self._validate_data(collection, {**data, "mission_id": self.mission_id}) + async with self.handle_grpc_errors("CreateRecord", StorageServiceError): + data_struct = Struct() + record = self._create_storage_record(collection, record_id or uuid4().hex, validated_data, data_type) + data_struct.update(record.data.model_dump()) + req = storage_dto_pb2.CreateRecordRequest( + data=data_struct, + mission_id=record.mission_id, + collection=record.collection, + record_id=record.record_id, + data_type=record.data_type.name, + ) + resp = await self.exec_grpc_query("CreateRecord", req) + return self._build_record_from_proto(resp.result.record) + + async def delete_collection(self, collection: str) -> bool: + """Delete all records in a collection via gRPC. + + Returns: + True if collection was deleted. + """ + async with self.handle_grpc_errors("DeleteCollection", StorageServiceError): + req = storage_dto_pb2.DeleteCollectionRequest( + mission_id=self.mission_id, + collection=collection, + ) + resp = await self.exec_grpc_query("DeleteCollection", req) + return resp.result.success diff --git a/src/digitalkin/services/storage/storage_strategy.py b/src/digitalkin/services/storage/storage_strategy.py index 0660373e..e46d6112 100644 --- a/src/digitalkin/services/storage/storage_strategy.py +++ b/src/digitalkin/services/storage/storage_strategy.py @@ -1,68 +1,39 @@ """This module contains the abstract base class for storage strategies.""" import asyncio -import datetime from abc import ABC, abstractmethod -from enum import Enum -from typing import Any, Literal, TypeGuard -from uuid import uuid4 +from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel -from digitalkin.services.base_strategy import BaseStrategy - - -class StorageServiceError(Exception): - """Base exception for Setup service errors.""" - - -class DataType(Enum): - """Enum defining the types of data that can be stored.""" - - OUTPUT = "OUTPUT" - VIEW = "VIEW" - LOGS = "LOGS" - OTHER = "OTHER" - - -class StorageRecord(BaseModel): - """A single record stored in a collection, with metadata.""" - - mission_id: str = Field(..., description="ID of the mission (bucket) this doc belongs to") - collection: str = Field(..., description="Logical collection name") - record_id: str = Field(..., description="Unique ID of this record in its collection") - data_type: DataType = Field(default=DataType.OUTPUT, description="Category of the data of this record") - data: BaseModel = Field(..., description="The typed payload of this record") - creation_date: datetime.datetime | None = Field(default=None, description="When this record was first created") - update_date: datetime.datetime | None = Field(default=None, description="When this record was last modified") +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.services.storage import DataType, StorageRecord class StorageStrategy(BaseStrategy, ABC): """Define CRUD + list/remove-collection against a collection/record store.""" - def _validate_data(self, collection: str, data: dict[str, Any]) -> BaseModel: - """Validate data against the model schema for the given key. + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + config: dict[str, type[BaseModel]], + ) -> None: + """Initialize the storage strategy. Args: - collection: The unique name for the record type - data: The data to validate - - Returns: - A validated model instance - - Raises: - ValueError: If the key has no associated model or validation fails + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version + config: A dictionary mapping names to Pydantic model classes """ - model_cls = self.config.get(collection) - if not model_cls: - msg = f"No schema registered for collection '{collection}'" - raise ValueError(msg) + super().__init__(mission_id, setup_id, setup_version_id) + # Schema configuration mapping keys to model classes + self.config: dict[str, type[BaseModel]] = config + self._record_locks: dict[str, asyncio.Lock] = {} - try: - return model_cls.model_validate(data) - except Exception as e: - msg = f"Validation failed for '{collection}': {e!s}" - raise ValueError(msg) from e + # ═════════════════════════════════ Private Methods ══════════════════════════════════ # def _create_storage_record( self, @@ -90,36 +61,49 @@ def _create_storage_record( data_type=data_type, ) - @staticmethod - def _is_valid_data_type_name(value: str) -> TypeGuard[str]: - return value in DataType.__members__ - - @abstractmethod - async def _store(self, record: StorageRecord) -> StorageRecord: - """Store a new record in the storage. + def _record_lock(self, collection: str, record_id: str) -> asyncio.Lock: + """Get or create an asyncio.Lock for a specific record. Args: - record: The record to store + collection: The collection name + record_id: The record ID Returns: - The ID of the created record + An asyncio.Lock scoped to the given collection:record_id pair. """ + return self._record_locks.setdefault(f"{collection}:{record_id}", asyncio.Lock()) - @abstractmethod - async def _read(self, collection: str, record_id: str) -> StorageRecord | None: - """Get records from storage by key. + # ════════════════════════════════ Protected Methods ═════════════════════════════════ # + + def _validate_data(self, collection: str, data: dict[str, Any]) -> BaseModel: + """Validate data against the model schema for the given key. Args: - collection: The unique name to retrieve data for - record_id: The unique ID of the record + collection: The unique name for the record type + data: The data to validate Returns: - A storage record with validated data + A validated model instance + + Raises: + ValueError: If the key has no associated model or validation fails """ + model_cls = self.config.get(collection) + if not model_cls: + msg = f"No schema registered for collection '{collection}'" + raise ValueError(msg) + + try: + return model_cls.model_validate(data) + except Exception as e: + msg = f"Validation failed for '{collection}': {e!s}" + raise ValueError(msg) from e + + # ════════════════════════════════ Overriding Methods ════════════════════════════════ # @abstractmethod - async def _update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: - """Overwrite an existing record's payload. + async def update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: + """Validate & overwrite an existing record. Args: collection: The unique name for the record type @@ -129,9 +113,10 @@ async def _update(self, collection: str, record_id: str, data: BaseModel) -> Sto Returns: StorageRecord: The modified record """ + return await super().update() @abstractmethod - async def _remove(self, collection: str, record_id: str) -> bool: + async def delete(self, collection: str, record_id: str) -> bool: """Delete a record from the storage. Args: @@ -141,69 +126,42 @@ async def _remove(self, collection: str, record_id: str) -> bool: Returns: True if the deletion was successful, False otherwise """ + return await super().delete() @abstractmethod - async def _list(self, collection: str) -> list[StorageRecord]: - """List all records in a collection. + async def get(self, collection: str, record_id: str) -> StorageRecord | None: + """Get records from storage by key. Args: - collection: The unique name for the record type + collection: The unique name to retrieve data for + record_id: The unique ID of the record Returns: - A list of storage records + A storage record with validated data """ + return await super().get() @abstractmethod - async def _remove_collection(self, collection: str) -> bool: - """Delete all records in a collection. + async def list(self, collection: str) -> list[StorageRecord]: + """Get all records within a collection. Args: collection: The unique name for the record type Returns: - True if the deletion was successful, False otherwise - """ - - def __init__( - self, - mission_id: str, - setup_id: str, - setup_version_id: str, - config: dict[str, type[BaseModel]], - ) -> None: - """Initialize the storage strategy. - - Args: - mission_id: The ID of the mission this strategy is associated with - setup_id: The ID of the setup - setup_version_id: The ID of the setup version - config: A dictionary mapping names to Pydantic model classes - """ - super().__init__(mission_id, setup_id, setup_version_id) - # Schema configuration mapping keys to model classes - self.config: dict[str, type[BaseModel]] = config - self._record_locks: dict[str, asyncio.Lock] = {} - - def _record_lock(self, collection: str, record_id: str) -> asyncio.Lock: - """Get or create an asyncio.Lock for a specific record. - - Args: - collection: The collection name - record_id: The record ID - - Returns: - An asyncio.Lock scoped to the given collection:record_id pair. + A list of storage records """ - return self._record_locks.setdefault(f"{collection}:{record_id}", asyncio.Lock()) + return await super().list() - async def store( + @abstractmethod + async def create( self, collection: str, record_id: str | None, - data: dict[str, Any], - data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT", + data: BaseModel, + data_type: DataType = DataType.OUTPUT, ) -> StorageRecord: - """Store a new record in the storage. + """Create a new record in the storage. Args: collection: The unique name for the record type @@ -217,125 +175,37 @@ async def store( Raises: ValueError: If the data type is invalid or if validation fails """ - if not self._is_valid_data_type_name(data_type): - msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}" - raise ValueError(msg) - record_id = record_id or uuid4().hex - data_type_enum = DataType[data_type] - validated_data = self._validate_data(collection, {**data, "mission_id": self.mission_id}) - record = self._create_storage_record(collection, record_id, validated_data, data_type_enum) - async with self._record_lock(collection, record_id): - return await self._store(record) - - async def read(self, collection: str, record_id: str) -> StorageRecord | None: - """Get records from storage by key. - - Args: - collection: The unique name to retrieve data for - record_id: The unique ID of the record - - Returns: - A storage record with validated data - """ - async with self._record_lock(collection, record_id): - return await self._read(collection, record_id) - - async def update(self, collection: str, record_id: str, data: dict[str, Any]) -> StorageRecord | None: - """Validate & overwrite an existing record. + return await super().create() - Args: - collection: The unique name for the record type - record_id: The unique ID of the record - data: The new data to store - - Returns: - StorageRecord: The modified record - """ - validated_data = self._validate_data(collection, data) - async with self._record_lock(collection, record_id): - return await self._update(collection, record_id, validated_data) + # ═══════════════════════════════ Abstract Methods ═══════════════════════════════ # - async def remove(self, collection: str, record_id: str) -> bool: - """Delete a record from the storage. + @abstractmethod + async def delete_collection(self, collection: str) -> bool: + """Delete all records in a collection. Args: collection: The unique name for the record type - record_id: The unique ID of the record Returns: True if the deletion was successful, False otherwise """ - key = f"{collection}:{record_id}" - async with self._record_lock(collection, record_id): - result = await self._remove(collection, record_id) - if result: - self._record_locks.pop(key, None) - return result + msg = "Delete collection method not implemented yet." + raise NotImplementedError(msg) - async def list(self, collection: str) -> list[StorageRecord]: - """Get all records within a collection. + # ════════════════════════════ Unimplemented Methods ═════════════════════════════ # - Args: - collection: The unique name for the record type + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. Returns: - A list of storage records + NotImplementedError from base class. """ - return await self._list(collection) + return await super().search(args, kwargs) - async def remove_collection(self, collection: str) -> bool: - """Wipe a record clean. - - Args: - collection: The unique name for the record type + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. Returns: - True if the deletion was successful, False otherwise + NotImplementedError from base class. """ - result = await self._remove_collection(collection) - if result: - prefix = f"{collection}:" - for key in [k for k in self._record_locks if k.startswith(prefix)]: - self._record_locks.pop(key, None) - return result - - async def upsert( - self, - collection: str, - record_id: str, - data: dict[str, Any], - data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT", - ) -> StorageRecord: - """Insert or update a record atomically. - - If a record with the given collection/record_id exists, it is updated; - otherwise a new record is created. The operation is protected by a - per-record lock to prevent races. - - Args: - collection: The unique name for the record type - record_id: The unique ID for the record - data: The data to store - data_type: The type of data being stored (default: OUTPUT) - - Returns: - The created or updated storage record - - Raises: - ValueError: If the data type is invalid or if validation fails - StorageServiceError: If update of an existing record fails unexpectedly - """ - if not self._is_valid_data_type_name(data_type): - msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}" - raise ValueError(msg) - data_type_enum = DataType[data_type] - validated_data = self._validate_data(collection, {**data, "mission_id": self.mission_id}) - async with self._record_lock(collection, record_id): - if await self._read(collection, record_id): - updated = await self._update(collection, record_id, validated_data) - if updated is None: - msg = f"Update failed for existing record '{collection}:{record_id}'" - raise StorageServiceError(msg) - return updated - record = self._create_storage_record(collection, record_id, validated_data, data_type_enum) - return await self._store(record) + return await super().upload(args, kwargs) diff --git a/src/digitalkin/services/task_manager/default_task_manager.py b/src/digitalkin/services/task_manager/default_task_manager.py index 67aea81e..b7277d05 100644 --- a/src/digitalkin/services/task_manager/default_task_manager.py +++ b/src/digitalkin/services/task_manager/default_task_manager.py @@ -18,16 +18,16 @@ class DefaultTaskManager(TaskManagerStrategy): def __init__( self, - mission_id: str = "", # noqa: ARG002 - setup_id: str = "", # noqa: ARG002 - setup_version_id: str = "", # noqa: ARG002 + _mission_id: str = "", + _setup_id: str = "", + _setup_version_id: str = "", ) -> None: """Initialize in-memory signal store. Args: - mission_id: Mission identifier (unused, required by init_strategy convention). - setup_id: Setup identifier (unused, required by init_strategy convention). - setup_version_id: Setup version identifier (unused, required by init_strategy convention). + _mission_id: Mission identifier (unused, required by init_strategy convention). + _setup_id: Setup identifier (unused, required by init_strategy convention). + _setup_version_id: Setup version identifier (unused, required by init_strategy convention). """ self._signals = {} self._subscribers = {} @@ -49,11 +49,11 @@ async def send_signal(self, task_id: str, data: dict[str, Any]) -> dict[str, Any queue.put_nowait(data) return data - async def subscribe_signals(self, task_id: str = "") -> tuple[str, AsyncGenerator[dict[str, Any], None]]: # noqa: ARG002 + async def subscribe_signals(self, _task_id: str = "") -> tuple[str, AsyncGenerator[dict[str, Any], None]]: """Subscribe to signal updates via an in-memory queue. Args: - task_id: Task identifier (unused in local mode, broadcasts all signals). + _task_id: Task identifier (unused in local mode, broadcasts all signals). Returns: Tuple of (subscription_id, async generator of signal dicts). diff --git a/src/digitalkin/services/task_manager/grpc_task_manager.py b/src/digitalkin/services/task_manager/grpc_task_manager.py index 68ffec18..c8e2c65d 100644 --- a/src/digitalkin/services/task_manager/grpc_task_manager.py +++ b/src/digitalkin/services/task_manager/grpc_task_manager.py @@ -72,8 +72,8 @@ async def release(cls, key: str) -> None: inst = cls._instances.get(key) # type: ignore[attr-defined] if inst is None: return - inst._refcount -= 1 # noqa: SLF001 - if inst._refcount <= 0: # noqa: SLF001 + inst._refcount -= 1 + if inst._refcount <= 0: cls._instances.pop(key, None) # type: ignore[attr-defined] await inst.close() @@ -118,7 +118,7 @@ def get_or_create( if key not in cls._instances: cls._instances[key] = cls(poll_fn, poll_interval, initial_poll_interval) inst = cls._instances[key] - inst._refcount += 1 # noqa: SLF001 + inst._refcount += 1 return inst @classmethod @@ -306,7 +306,7 @@ def get_or_create(cls, key: str, stub: Any, grpc_timeout: float) -> _SharedSendB if key not in cls._instances: cls._instances[key] = cls(stub, grpc_timeout) inst = cls._instances[key] - inst._refcount += 1 # noqa: SLF001 + inst._refcount += 1 return inst def __init__(self, stub: Any, grpc_timeout: float) -> None: @@ -435,9 +435,9 @@ class GrpcTaskManager(TaskManagerStrategy, GrpcClientWrapper, GrpcErrorHandlerMi def __init__( self, - mission_id: str, # noqa: ARG002 - setup_id: str, # noqa: ARG002 - setup_version_id: str, # noqa: ARG002 + _mission_id: str, + _setup_id: str, + _setup_version_id: str, client_config: ClientConfig, *, poll_interval: float = float(os.environ.get("DIGITALKIN_SIGNAL_POLL_INTERVAL", "1.0")), @@ -446,9 +446,9 @@ def __init__( """Initialize with client config. Args: - mission_id: Mission identifier (unused, required by init_strategy convention). - setup_id: Setup identifier (unused, required by init_strategy convention). - setup_version_id: Setup version identifier (unused, required by init_strategy convention). + _mission_id: Mission identifier (unused, required by init_strategy convention). + _setup_id: Setup identifier (unused, required by init_strategy convention). + _setup_version_id: Setup version identifier (unused, required by init_strategy convention). client_config: gRPC client configuration. poll_interval: Maximum seconds between GetSignals polls. initial_poll_interval: Starting poll interval before exponential ramp-up. @@ -565,7 +565,7 @@ async def send_signal(self, task_id: str, data: dict[str, Any]) -> dict[str, Any signal = SignalMessage.model_validate(data) logger.debug("SendSignals queued: task_id=%s action=%s", task_id, signal.action.value) if self._send_buffer_acquired: - buffer = _SharedSendBuffer._instances.get(self._send_buffer_key) # noqa: SLF001 + buffer = _SharedSendBuffer._instances.get(self._send_buffer_key) else: self._send_buffer_acquired = True buffer = None diff --git a/src/digitalkin/services/user_profile/__init__.py b/src/digitalkin/services/user_profile/__init__.py index 1cb8d184..546b36ba 100644 --- a/src/digitalkin/services/user_profile/__init__.py +++ b/src/digitalkin/services/user_profile/__init__.py @@ -1,7 +1,7 @@ """UserProfile service package.""" -from digitalkin.services.user_profile.default_user_profile import DefaultUserProfile -from digitalkin.services.user_profile.grpc_user_profile import GrpcUserProfile +from digitalkin.services.user_profile.user_profile_default import DefaultUserProfile +from digitalkin.services.user_profile.user_profile_grpc import GrpcUserProfile from digitalkin.services.user_profile.user_profile_strategy import UserProfileServiceError, UserProfileStrategy __all__ = [ diff --git a/src/digitalkin/services/user_profile/default_user_profile.py b/src/digitalkin/services/user_profile/user_profile_default.py similarity index 74% rename from src/digitalkin/services/user_profile/default_user_profile.py rename to src/digitalkin/services/user_profile/user_profile_default.py index 12705c74..69a2851e 100644 --- a/src/digitalkin/services/user_profile/default_user_profile.py +++ b/src/digitalkin/services/user_profile/user_profile_default.py @@ -22,10 +22,14 @@ def __init__( setup_id: The ID of the setup setup_version_id: The ID of the setup version """ - super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id) + super().__init__( + mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, client_config=None + ) self.db: dict[str, dict[str, Any]] = {} - async def get_user_profile(self) -> dict[str, Any] | None: + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def get(self) -> dict[str, Any]: """Get user profile from in-memory storage. Returns: @@ -38,7 +42,7 @@ async def get_user_profile(self) -> dict[str, Any] | None: logger.debug("Retrieved user profile for mission_id: %s", self.mission_id) return self.db[self.mission_id] - def add_user_profile(self, user_profile_data: dict[str, Any]) -> None: + async def add_user_profile(self, user_profile_data: dict[str, Any]) -> None: """Add a user profile to the in-memory database (helper for testing). Args: diff --git a/src/digitalkin/services/user_profile/grpc_user_profile.py b/src/digitalkin/services/user_profile/user_profile_grpc.py similarity index 63% rename from src/digitalkin/services/user_profile/grpc_user_profile.py rename to src/digitalkin/services/user_profile/user_profile_grpc.py index e107f7c2..d400b4c4 100644 --- a/src/digitalkin/services/user_profile/grpc_user_profile.py +++ b/src/digitalkin/services/user_profile/user_profile_grpc.py @@ -3,16 +3,16 @@ from typing import Any from agentic_mesh_protocol.user_profile.v1 import ( - user_profile_pb2, + user_profile_dto_pb2, user_profile_service_pb2_grpc, ) +from google.protobuf import json_format from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper from digitalkin.grpc_servers.utils.grpc_error_handler import GrpcErrorHandlerMixin from digitalkin.logger import logger from digitalkin.models.grpc_servers.models import ClientConfig from digitalkin.services.user_profile.user_profile_strategy import UserProfileServiceError, UserProfileStrategy -from digitalkin.utils.proto_utils import proto_to_dict class GrpcUserProfile(UserProfileStrategy, GrpcClientWrapper, GrpcErrorHandlerMixin): @@ -35,12 +35,16 @@ def __init__( setup_version_id: The ID of the setup version client_config: Client configuration for gRPC connection """ - super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id) + super().__init__( + mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, client_config=client_config + ) channel = self._init_channel(client_config) self.stub = user_profile_service_pb2_grpc.UserProfileServiceStub(channel) logger.debug("Channel client 'UserProfile' initialized successfully") - async def get_user_profile(self) -> dict[str, Any] | None: + # ══════════════════════════════════ Public Methods ══════════════════════════════════ # + + async def get(self) -> dict[str, Any]: """Get user profile by mission_id (which maps to user_id). Returns: @@ -50,14 +54,21 @@ async def get_user_profile(self) -> dict[str, Any] | None: UserProfileServiceError: If the gRPC operation fails. """ async with self.handle_grpc_errors("GetUserProfile", UserProfileServiceError): - request = user_profile_pb2.GetUserProfileRequest(mission_id=self.mission_id) + # mission_id typically contains user context + request = user_profile_dto_pb2.GetUserProfileRequest(mission_id=self.mission_id) response = await self.exec_grpc_query("GetUserProfile", request) - if not response.success: - logger.warning("No user profile found for mission_id: %s", self.mission_id) - return None + if not response.result.success: + msg = f"Failed to get user profile for mission_id: {self.mission_id}" + logger.error(msg) + raise UserProfileServiceError(msg) - user_profile_dict = proto_to_dict(response.user_profile, with_defaults=True) + # Convert proto to dict + user_profile_dict = json_format.MessageToDict( + response.result.profile, + preserving_proto_field_name=True, + always_print_fields_with_no_presence=True, + ) logger.debug("Retrieved user profile for mission_id: %s", self.mission_id) return user_profile_dict diff --git a/src/digitalkin/services/user_profile/user_profile_strategy.py b/src/digitalkin/services/user_profile/user_profile_strategy.py index 46a2594c..92ad57c3 100644 --- a/src/digitalkin/services/user_profile/user_profile_strategy.py +++ b/src/digitalkin/services/user_profile/user_profile_strategy.py @@ -3,7 +3,8 @@ from abc import ABC, abstractmethod from typing import Any -from digitalkin.services.base_strategy import BaseStrategy +from digitalkin.models.base_strategy import BaseStrategy +from digitalkin.models.grpc_servers.models import ClientConfig class UserProfileServiceError(Exception): @@ -13,8 +14,28 @@ class UserProfileServiceError(Exception): class UserProfileStrategy(BaseStrategy, ABC): """Abstract base class for UserProfile strategies.""" + def __init__( + self, + mission_id: str, + setup_id: str, + setup_version_id: str, + client_config: ClientConfig, + ) -> None: + """Initialize the user profile strategy. + + Args: + mission_id: The ID of the mission this strategy is associated with + setup_id: The ID of the setup + setup_version_id: The ID of the setup version + client_config: Client configuration for connecting to the user profile service + """ + super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id) + self.client_config = client_config + + # ════════════════════════════════ Overriting Methods ════════════════════════════════ # + @abstractmethod - async def get_user_profile(self) -> dict[str, Any] | None: + async def get(self) -> dict[str, Any]: """Get user profile data. Returns: @@ -23,3 +44,54 @@ async def get_user_profile(self) -> dict[str, Any] | None: Raises: UserProfileServiceError: If the service call fails (not for missing profiles). """ + return await super().get() + + # ══════════════════════════════ Unimplemented Methods ═══════════════════════════════ # + + async def create(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().create(args, kwargs) + + async def list(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().list(args, kwargs) + + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().search(args, kwargs) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().delete(args, kwargs) + + async def update(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().update(args, kwargs) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented. + + Returns: + NotImplementedError from base class. + """ + return await super().upload(args, kwargs) diff --git a/src/digitalkin/utils/arg_parser.py b/src/digitalkin/utils/arg_parser.py index 5f41c753..e4f553ad 100644 --- a/src/digitalkin/utils/arg_parser.py +++ b/src/digitalkin/utils/arg_parser.py @@ -65,15 +65,15 @@ class HelpAction(_HelpAction): def __call__( self, parser: ArgumentParser, - namespace: Namespace, # argparse _HelpAction.__call__ signature # noqa: ARG002 - values: str | Sequence[Any] | None, # argparse _HelpAction.__call__ signature # noqa: ARG002 - option_string: str | None = None, # argparse _HelpAction.__call__ signature # noqa: ARG002 + _namespace: Namespace, # argparse _HelpAction.__call__ signature + _values: str | Sequence[Any] | None, # argparse _HelpAction.__call__ signature + _option_string: str | None = None, # argparse _HelpAction.__call__ signature ) -> None: """Override the HelpActions as it doesn't handle subparser well.""" parser.print_help() subparsers_actions = [ action - for action in parser._actions # noqa: SLF001 + for action in parser._actions if isinstance(action, _SubParsersAction) ] # Private argparse API needed for subparser enumeration for subparsers_action in subparsers_actions: diff --git a/src/digitalkin/utils/development_mode_action.py b/src/digitalkin/utils/development_mode_action.py index 8417b1b8..5b05fda1 100644 --- a/src/digitalkin/utils/development_mode_action.py +++ b/src/digitalkin/utils/development_mode_action.py @@ -18,7 +18,7 @@ class DevelopmentModeMappingAction(Action): def __init__( self, env_var: str, - required: bool = True, # argparse Action API convention # noqa: FBT001, FBT002 + required: bool = True, # noqa: FBT001, FBT002 default: str | None = None, **kwargs: Any, ) -> None: @@ -35,10 +35,10 @@ def __init__( def __call__( self, - parser: ArgumentParser, # argparse Action.__call__ signature # noqa: ARG002 + _parser: ArgumentParser, # argparse Action.__call__ signature namespace: Namespace, values: str | Sequence[Any] | None, - option_string: str | None = None, # argparse Action.__call__ signature # noqa: ARG002 + _option_string: str | None = None, # argparse Action.__call__ signature ) -> None: """Set the attribute to the corresponding class. diff --git a/src/digitalkin/utils/llm_ready_schema.py b/src/digitalkin/utils/llm_ready_schema.py index defb397c..147d1cae 100644 --- a/src/digitalkin/utils/llm_ready_schema.py +++ b/src/digitalkin/utils/llm_ready_schema.py @@ -16,13 +16,13 @@ class CustomOrderSchema(GenerateJsonSchema): def sort( self, value: JsonSchemaValue, - parent_key: str | None = None, # noqa: ARG002 + _parent_key: str | None = None, ) -> JsonSchemaValue: # Overrides Pydantic GenerateJsonSchema.sort signature """Sort the keys of the schema in a specific order. Args: value: The schema value to sort. - parent_key: The parent key of the schema value. + _parent_key: The parent key of the schema value. Returns: The sorted schema value. diff --git a/src/digitalkin/utils/schema_splitter.py b/src/digitalkin/utils/schema_splitter.py index 0cb00755..0cb469b2 100644 --- a/src/digitalkin/utils/schema_splitter.py +++ b/src/digitalkin/utils/schema_splitter.py @@ -29,7 +29,7 @@ def split(cls, combined_schema: dict[str, Any]) -> tuple[dict[str, Any], dict[st return json_schema, ui_schema @classmethod - def _extract_ui_properties( # Complex: recursive traversal of nested JSON schema structures # noqa: C901, PLR0912 + def _extract_ui_properties( # noqa: C901, PLR0912 cls, source: dict[str, Any], ui_target: dict[str, Any], @@ -79,7 +79,7 @@ def _extract_ui_properties( # Complex: recursive traversal of nested JSON schem cls._extract_ui_properties(defs[def_name], ui_target, defs) @classmethod - def _process_object( # Complex: JSON schema node splitting into json/ui # noqa: C901, PLR0912, PLR0915 + def _process_object( # noqa: C901, PLR0912, PLR0915 cls, source: dict[str, Any], json_target: dict[str, Any], @@ -158,7 +158,7 @@ def _process_object( # Complex: JSON schema node splitting into json/ui # noqa: json_target[key] = value @classmethod - def _process_property( # Complex: recursive property splitting with $ref resolution # noqa: C901, PLR0912 + def _process_property( # noqa: C901, PLR0912 cls, source: dict[str, Any], json_target: dict[str, Any], @@ -222,7 +222,7 @@ def _process_property( # Complex: recursive property splitting with $ref resolu @classmethod def _strip_ui_properties( # noqa: C901, PLR0912 cls, source: dict[str, Any], json_target: dict[str, Any] - ) -> None: # Complex: recursive traversal of nested JSON schema structures + ) -> None: """Copy source to json_target, stripping ui:* properties. Args: diff --git a/taskfile.yaml b/taskfile.yaml index 388d12d4..74a123c0 100644 --- a/taskfile.yaml +++ b/taskfile.yaml @@ -1,17 +1,58 @@ version: "3" vars: - # could use env var PACKAGE_NAME: "digitalkin" PACKAGE_DIR: "src/{{.PACKAGE_NAME}}" + PYTHON_VERSION: "3.10" + tasks: - venv: - desc: "Install project venv" + # ============================================================================= + # DEFAULT - Shortcuts for common tasks + # ============================================================================= + default: + desc: "Show available tasks" + cmds: + - task --list + silent: true + + # ============================================================================= + # SETUP - Environment and initial configuration + # ============================================================================= + setup: + desc: "Setup project environment (usage: task setup[:venv|:dev|:pre-commit])" + cmds: + - task: setup:dev + + setup:venv: + desc: "Create virtual environment" + cmds: + - uv venv --python {{.PYTHON_VERSION}} + + setup:pre-commit: + desc: "Install pre-commit hooks" + cmds: + - uv run pre-commit install + + setup:dev: + desc: "Setup complete development environment" cmds: - - uv venv --python 3.10 + - task: setup:venv + - task: install:deps + - task: install:dev + - task: install:tests + - task: setup:pre-commit - install-deps: + # ============================================================================= + # INSTALL - Dependencies installation + # ============================================================================= + install: + desc: "Install project dependencies (usage: task install[:deps|:dev|:tests|:examples|:all])" + aliases: [ i ] + cmds: + - task: install:dev + + install:deps: desc: "Install project dependencies from pyproject.toml" cmds: - uv pip compile pyproject.toml -o requirements.txt @@ -24,119 +65,171 @@ tasks: uv pip install -e . --system fi - dev-deps: + install:dev: desc: "Install development dependencies" cmds: - - uv sync --extra taskiq --group dev --group docs # uv pip install -e ".[taskiq]" --group dev --group docs + - uv pip install -e ".[taskiq]" --group dev --group docs - examples-deps: + install:tests: + desc: "Install tests dependencies" + cmds: + - uv pip install --group tests + + install:examples: desc: "Install examples dependencies" cmds: - - uv sync --group examples + - uv pip install --group examples - tests-deps: - desc: "Install tests dependencies" + install:all: + desc: "Install all dependencies (deps + dev + tests + examples)" cmds: - - uv sync --group tests + - task: install:deps + - task: install:dev + - task: install:tests + - task: install:examples - setup-pre-commit: - desc: "Install pre-commit hooks" + # ============================================================================= + # BUILD - Package building + # ============================================================================= + build: + desc: "Build the project (usage: task build[:package|:verify])" cmds: - - uv run pre-commit install + - task: build:package - build-package: - desc: "Build the PyPI package (runs your build script)" + build:package: + desc: "Build the PyPI package" cmds: - uv build - generate-certificates: - desc: "Generate certificates" - # You can customize the certificate generation with various options: - # python generate_certificates.py --output-dir ./my-certs --key-size 4096 --dns-names localhost myserver.example.com --ip-addresses 127.0.0.1 192.168.1.100 + build:verify: + desc: "Build and verify the package can be imported" cmds: - - uv run python scripts/generate_certificates.py + - task: build:package + - uv run --with {{.PACKAGE_NAME}} --no-project -- python -c 'import {{.PACKAGE_NAME}}; print({{.PACKAGE_NAME}}.__version__)' - publish-package-test: - desc: "Publish the package to the PyPI's test env" + # ============================================================================= + # TEST - Testing + # ============================================================================= + test: + desc: "Run tests (usage: task test[:unit|:all])" + aliases: [ tests ] cmds: - - uv publish --repository-url https://test.pypi.org/legacy/ + - task: test:all - publish-package: - desc: "Publish the package to PyPI" + test:unit: + desc: "Run unit tests (usage: task test:unit [-- tests/path/to/test])" + vars: + TEST_PATH: '{{if .CLI_ARGS}}{{.CLI_ARGS}}{{else}}tests{{end}}' cmds: - - uv publish + - docker compose run --rm -T -e TEST_SELECTOR="{{.TEST_PATH}}" tests - test-package: - desc: "Test if the PyPI package is well published" + test:all: + desc: "Run all tests" cmds: - - task: build-package - - uv run --with {{.PACKAGE_NAME}} --no-project -- python -c 'import {{.PACKAGE_NAME}}; print({{.PACKAGE_NAME}}.__version__)' + - docker compose run --rm -T tests + - + test:clean: + desc: "Clean test artifacts, cache, " + cmds: + - rm -rf dist/agentic_mesh_protocol-*.tar.gz + - echo "agentic_mesh_protocol packages removed from dist/ directory" - run-tests: - desc: "Run pytest tests" + # ============================================================================= + # LINT - Code quality and formatting + # ============================================================================= + lint: + desc: "Run all linting tasks" cmds: - - docker compose run --rm -T tests + - task: lint:check - linter: - desc: "run linter on the project" + lint:format: + desc: "Format code with ruff" cmds: - - | - uv run ruff format . && uv run ruff check --select I --fix . && uv run ruff check . --fix - - uv run mypy src/{{.PACKAGE_NAME}} + - uv run ruff format . + + lint:check: + desc: "Check code with ruff" + cmds: + - uv run ruff check . + + lint:fix: + desc: "Fix linting issues with ruff" + cmds: + - uv run ruff check --select I --fix . + - uv run ruff check . --fix + + lint:all: + desc: "Format and fix all linting issues" + cmds: + - task: lint:format + - task: lint:fix + + # ============================================================================= + # PUBLISH - Package publishing + # ============================================================================= + publish:test: + desc: "Publish the package to PyPI test repository" + cmds: + - uv publish --repository-url https://test.pypi.org/legacy/ + publish:prod: + desc: "Publish the package to PyPI" + cmds: + - uv publish + + publish:verify: + desc: "Publish to test PyPI and verify the package" + cmds: + - task: publish:test + - task: build:verify + + # ============================================================================= + # CLEAN - Cleanup tasks + # ============================================================================= clean: + desc: "Clean build artifacts and cache" + cmds: + - task: clean:build + + clean:build: desc: "Remove build artifacts and cache directories" cmds: - rm -rf dist src/{{.PACKAGE_NAME}}.egg-info - find . -type d -name "__pycache__" -exec rm -rf {} + - find . -type d -name "*.egg-info" -exec rm -rf {} + - clean-all: - desc: "Deep clean venv and dist" + clean:all: + desc: "Deep clean: build artifacts, cache, and virtual environment" cmds: - - task: clean - # Clean up virtual environment + - task: clean:build - rm -rf .venv - rm -rf dist - test-publish: - desc: "push and test the package in a test env" - cmds: - - task: publish-package-test - - task: test-package - - bump-version: - desc: "Bump package version (type: major, minor, patch, pre_l or pre_n)" + # ============================================================================= + # VERSION - Version management + # ============================================================================= + version:bump: + desc: "Bump package version (usage: task version:bump -- patch|minor|major|pre_l|pre_n)" cmds: - SKIP=pytest bump-my-version bump {{.CLI_ARGS}} - setup-dev: - desc: "Setup development environment" - cmds: - - task: venv - - task: install-deps - - task: dev-deps - - task: tests-deps - - task: setup-pre-commit - - docs-serve: - desc: "Serve documentation locally" - cmds: - - uv run mkdocs serve + # ============================================================================= + # GEN - Generation tasks + # ============================================================================= + gen:certs: + desc: "Generate SSL certificates" + summary: | + Generate SSL certificates for secure communication. - docs-build: - desc: "Build documentation" + Customize with: python generate_certificates.py --output-dir ./my-certs --key-size 4096 + --dns-names localhost myserver.example.com --ip-addresses 127.0.0.1 192.168.1.100 cmds: - - uv run mkdocs build - - check: - desc: "Run linter, type check, and tests" - cmds: - - task: linter - - uv run mypy src/{{.PACKAGE_NAME}} - - task: run-tests + - uv run python scripts/generate_certificates.py - start-taskiq: - desc: "Start TaskIQ worker. be sure to enable rabbitMQ stream capability" + # ============================================================================= + # RUN - Runtime services + # ============================================================================= + run:taskiq: + desc: "Start TaskIQ worker (requires RabbitMQ stream capability)" cmds: - taskiq worker digitalkin.core.job_manager.taskiq_broker:TASKIQ_BROKER -w 1 diff --git a/tests/fixtures/strict_assertions.py b/tests/fixtures/strict_assertions.py index a7dd023f..3f9222da 100644 --- a/tests/fixtures/strict_assertions.py +++ b/tests/fixtures/strict_assertions.py @@ -386,7 +386,7 @@ def install(self) -> None: self.old_handler = loop.get_exception_handler() def handler(loop, context) -> None: - self.exceptions.append(context.get("exception")) + self.exceptions.append(context.list("exception")) if self.old_handler: self.old_handler(loop, context) diff --git a/tests/grpc_server/test_module_service.py b/tests/grpc_server/test_module_service.py index 65b94ed7..a2a462e9 100644 --- a/tests/grpc_server/test_module_service.py +++ b/tests/grpc_server/test_module_service.py @@ -12,15 +12,15 @@ import grpc import pytest from agentic_mesh_protocol.module.v1 import ( - information_pb2, - lifecycle_pb2, - monitoring_pb2, + module_dto_pb2, ) -from agentic_mesh_protocol.setup.v1 import setup_pb2 +from agentic_mesh_protocol.setup.v1.setup_messages_pb2 import SetupVersion from google.protobuf import json_format, struct_pb2 +from digitalkin import ModuleContext from digitalkin.core.job_manager.base_job_manager import BaseJobManager from digitalkin.grpc_servers.module_servicer import ModuleServicer +from digitalkin.models.module.base_types import SetupModelT from digitalkin.modules._base_module import BaseModule from tests.fixtures.grpc_fixtures import FakeContext @@ -81,6 +81,12 @@ async def get_config_setup_format(cls, *, llm_format: bool = False) -> str: # n """Mock config setup format schema.""" return '{"type": "object", "properties": {"setup_config": {"type": "string"}}}' + async def initialize(self, context: ModuleContext, setup_data: SetupModelT) -> None: + pass + + async def cleanup(self) -> None: + pass + @pytest.fixture def mock_job_manager(): @@ -97,8 +103,8 @@ def mock_job_manager(): @pytest.fixture def mock_setup_strategy(): """Create a mock setup strategy.""" - setup_mock = Mock() - setup_data = Mock() + setup_mock = AsyncMock() + setup_data = AsyncMock() setup_data.current_setup_version.content = {"test": "setup"} setup_data.current_setup_version.setup_id = "setup-123" setup_data.current_setup_version.id = "version-123" @@ -139,7 +145,7 @@ async def test_start_module_success(self, module_servicer, fake_context, mock_jo {"message": "test"}, struct_pb2.Struct(), ) - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( setup_id="setup-123", mission_id="mission-456", input=input_struct, @@ -164,9 +170,9 @@ async def mock_stream() -> AsyncGenerator[dict[str, Any], None]: # noqa: RUF029 # Verify: 2 data messages + 1 end_of_stream message assert len(responses) == 3 - assert responses[0].success is True + assert responses[0].result.success is True assert responses[0].job_id == "test-job-id" - assert responses[-1].success is True # End of stream + assert responses[-1].result.success is True # End of stream mock_job_manager.create_module_instance_job.assert_called_once() mock_job_manager.clean_session.assert_called_once_with("test-job-id", mission_id="mission-456") @@ -175,9 +181,9 @@ async def mock_stream() -> AsyncGenerator[dict[str, Any], None]: # noqa: RUF029 async def test_start_module_no_setup_data(self, module_servicer, fake_context): """Test module start returns failure response when setup data is not found.""" # Mock setup to return None - module_servicer.setup.get_setup = AsyncMock(return_value=None) + module_servicer.setup.get = AsyncMock(return_value=None) - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( setup_id="invalid-setup", mission_id="mission-456", input=struct_pb2.Struct(), @@ -188,7 +194,7 @@ async def test_start_module_no_setup_data(self, module_servicer, fake_context): # Verify - should get a single failure response with proper gRPC status assert len(responses) == 1 - assert responses[0].success is False + assert responses[0].result.success is False assert fake_context._code == grpc.StatusCode.NOT_FOUND assert "No setup data found" in fake_context._details @@ -198,7 +204,7 @@ async def test_start_module_job_creation_fails(self, module_servicer, fake_conte # Setup mock_job_manager.create_module_instance_job = AsyncMock(return_value=None) - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( setup_id="setup-123", mission_id="mission-456", input=struct_pb2.Struct(), @@ -209,7 +215,7 @@ async def test_start_module_job_creation_fails(self, module_servicer, fake_conte # Verify assert len(responses) == 1 - assert responses[0].success is False + assert responses[0].result.success is False assert fake_context.get_code() == grpc.StatusCode.NOT_FOUND assert "Failed to create module instance" in fake_context.get_details() @@ -222,7 +228,7 @@ async def test_start_module_with_error_in_stream(self, module_servicer, fake_con This test expects that KeyError. """ # Setup request - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( setup_id="setup-123", mission_id="mission-456", input=struct_pb2.Struct(), @@ -258,7 +264,7 @@ async def test_start_module_with_exception_in_stream(self, module_servicer, fake This test expects that KeyError. """ # Setup request - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( setup_id="setup-123", mission_id="mission-456", input=struct_pb2.Struct(), @@ -287,11 +293,11 @@ class TestStopModule: @pytest.mark.asyncio async def test_stop_module_success(self, module_servicer, fake_context, mock_job_manager): """Test successful module stop.""" - request = lifecycle_pb2.StopModuleRequest(job_id="test-job-id") + request = module_dto_pb2.StopModuleRequest(job_id="test-job-id") response = await module_servicer.StopModule(request, fake_context) - assert response.success is True + assert response.result.success is True mock_job_manager.stop_module.assert_called_once_with("test-job-id") @pytest.mark.asyncio @@ -299,11 +305,11 @@ async def test_stop_module_not_found(self, module_servicer, fake_context, mock_j """Test stop module when job is not found.""" mock_job_manager.stop_module = AsyncMock(return_value=False) - request = lifecycle_pb2.StopModuleRequest(job_id="nonexistent-job") + request = module_dto_pb2.StopModuleRequest(job_id="nonexistent-job") response = await module_servicer.StopModule(request, fake_context) - assert response.success is False + assert response.result.success is False assert fake_context.get_code() == grpc.StatusCode.NOT_FOUND assert "not found" in fake_context.get_details() @@ -314,28 +320,28 @@ class TestGetModuleInput: @pytest.mark.asyncio async def test_get_module_input_success(self, module_servicer, fake_context): """Test successful retrieval of module input schema.""" - request = information_pb2.GetModuleInputRequest(llm_format=False) + request = module_dto_pb2.GetModuleInputRequest(llm_format=False) response = await module_servicer.GetModuleInput(request, fake_context) - assert response.success is True - assert response.input_schema is not None + assert response.result.success is True + assert response.result.input_schema is not None @pytest.mark.asyncio async def test_get_module_input_llm_format(self, module_servicer, fake_context): """Test retrieval of module input schema in LLM format.""" - request = information_pb2.GetModuleInputRequest(llm_format=True) + request = module_dto_pb2.GetModuleInputRequest(llm_format=True) response = await module_servicer.GetModuleInput(request, fake_context) - assert response.success is True - assert response.input_schema is not None + assert response.result.success is True + assert response.result.input_schema is not None @pytest.mark.asyncio async def test_get_module_input_not_implemented(self, module_servicer, fake_context): """Test get module input when format is not implemented.""" with patch.object(MockModule, "get_input_format", side_effect=NotImplementedError("Not implemented")): - request = information_pb2.GetModuleInputRequest(llm_format=False) + request = module_dto_pb2.GetModuleInputRequest(llm_format=False) await module_servicer.GetModuleInput(request, fake_context) @@ -349,28 +355,28 @@ class TestGetModuleOutput: @pytest.mark.asyncio async def test_get_module_output_success(self, module_servicer, fake_context): """Test successful retrieval of module output schema.""" - request = information_pb2.GetModuleOutputRequest(llm_format=False) + request = module_dto_pb2.GetModuleOutputRequest(llm_format=False) response = await module_servicer.GetModuleOutput(request, fake_context) - assert response.success is True - assert response.output_schema is not None + assert response.result.success is True + assert response.result.output_schema is not None @pytest.mark.asyncio async def test_get_module_output_llm_format(self, module_servicer, fake_context): """Test retrieval of module output schema in LLM format.""" - request = information_pb2.GetModuleOutputRequest(llm_format=True) + request = module_dto_pb2.GetModuleOutputRequest(llm_format=True) response = await module_servicer.GetModuleOutput(request, fake_context) - assert response.success is True - assert response.output_schema is not None + assert response.result.success is True + assert response.result.output_schema is not None @pytest.mark.asyncio async def test_get_module_output_not_implemented(self, module_servicer, fake_context): """Test get module output when format is not implemented.""" with patch.object(MockModule, "get_output_format", side_effect=NotImplementedError("Not implemented")): - request = information_pb2.GetModuleOutputRequest(llm_format=False) + request = module_dto_pb2.GetModuleOutputRequest(llm_format=False) await module_servicer.GetModuleOutput(request, fake_context) @@ -383,28 +389,28 @@ class TestGetModuleSetup: @pytest.mark.asyncio async def test_get_module_setup_success(self, module_servicer, fake_context): """Test successful retrieval of module setup schema.""" - request = information_pb2.GetModuleSetupRequest(llm_format=False) + request = module_dto_pb2.GetModuleSetupRequest(llm_format=False) response = await module_servicer.GetModuleSetup(request, fake_context) - assert response.success is True - assert response.setup_schema is not None + assert response.result.success is True + assert response.result.setup_schema is not None @pytest.mark.asyncio async def test_get_module_setup_llm_format(self, module_servicer, fake_context): """Test retrieval of module setup schema in LLM format.""" - request = information_pb2.GetModuleSetupRequest(llm_format=True) + request = module_dto_pb2.GetModuleSetupRequest(llm_format=True) response = await module_servicer.GetModuleSetup(request, fake_context) - assert response.success is True - assert response.setup_schema is not None + assert response.result.success is True + assert response.result.setup_schema is not None @pytest.mark.asyncio async def test_get_module_setup_not_implemented(self, module_servicer, fake_context): """Test get module setup when format is not implemented.""" with patch.object(MockModule, "get_setup_format", side_effect=NotImplementedError("Not implemented")): - request = information_pb2.GetModuleSetupRequest(llm_format=False) + request = module_dto_pb2.GetModuleSetupRequest(llm_format=False) await module_servicer.GetModuleSetup(request, fake_context) @@ -417,28 +423,28 @@ class TestGetModuleSecret: @pytest.mark.asyncio async def test_get_module_secret_success(self, module_servicer, fake_context): """Test successful retrieval of module secret schema.""" - request = information_pb2.GetModuleSecretRequest(llm_format=False) + request = module_dto_pb2.GetModuleSecretRequest(llm_format=False) response = await module_servicer.GetModuleSecret(request, fake_context) - assert response.success is True - assert response.secret_schema is not None + assert response.result.success is True + assert response.result.secret_schema is not None @pytest.mark.asyncio async def test_get_module_secret_llm_format(self, module_servicer, fake_context): """Test retrieval of module secret schema in LLM format.""" - request = information_pb2.GetModuleSecretRequest(llm_format=True) + request = module_dto_pb2.GetModuleSecretRequest(llm_format=True) response = await module_servicer.GetModuleSecret(request, fake_context) - assert response.success is True - assert response.secret_schema is not None + assert response.result.success is True + assert response.result.secret_schema is not None @pytest.mark.asyncio async def test_get_module_secret_not_implemented(self, module_servicer, fake_context): """Test get module secret when format is not implemented.""" with patch.object(MockModule, "get_secret_format", side_effect=NotImplementedError("Not implemented")): - request = information_pb2.GetModuleSecretRequest(llm_format=False) + request = module_dto_pb2.GetModuleSecretRequest(llm_format=False) await module_servicer.GetModuleSecret(request, fake_context) @@ -451,28 +457,28 @@ class TestGetConfigSetupModule: @pytest.mark.asyncio async def test_get_config_setup_module_success(self, module_servicer, fake_context): """Test successful retrieval of config setup schema.""" - request = information_pb2.GetConfigSetupModuleRequest(llm_format=False) + request = module_dto_pb2.GetConfigSetupModuleRequest(llm_format=False) response = await module_servicer.GetConfigSetupModule(request, fake_context) - assert response.success is True - assert response.config_setup_schema is not None + assert response.result.success is True + assert response.result.config_setup_schema is not None @pytest.mark.asyncio async def test_get_config_setup_module_llm_format(self, module_servicer, fake_context): """Test retrieval of config setup schema in LLM format.""" - request = information_pb2.GetConfigSetupModuleRequest(llm_format=True) + request = module_dto_pb2.GetConfigSetupModuleRequest(llm_format=True) response = await module_servicer.GetConfigSetupModule(request, fake_context) - assert response.success is True - assert response.config_setup_schema is not None + assert response.result.success is True + assert response.result.config_setup_schema is not None @pytest.mark.asyncio async def test_get_config_setup_module_not_implemented(self, module_servicer, fake_context): """Test get config setup when format is not implemented.""" with patch.object(MockModule, "get_config_setup_format", side_effect=NotImplementedError("Not implemented")): - request = information_pb2.GetConfigSetupModuleRequest(llm_format=False) + request = module_dto_pb2.GetConfigSetupModuleRequest(llm_format=False) await module_servicer.GetConfigSetupModule(request, fake_context) @@ -486,13 +492,13 @@ class TestConfigSetupModule: async def test_config_setup_module_success(self, module_servicer, fake_context, mock_job_manager): """Test successful module setup configuration.""" # Create setup version using the correct import - setup_version = setup_pb2.SetupVersion( + setup_version = SetupVersion( id="version-123", setup_id="setup-123", content=json_format.ParseDict({"existing": "config"}, struct_pb2.Struct()), ) - request = lifecycle_pb2.ConfigSetupModuleRequest( + request = module_dto_pb2.ConfigSetupModuleRequest( mission_id="mission-456", setup_version=setup_version, content=json_format.ParseDict({"new": "config"}, struct_pb2.Struct()), @@ -500,8 +506,8 @@ async def test_config_setup_module_success(self, module_servicer, fake_context, response = await module_servicer.ConfigSetupModule(request, fake_context) - assert response.success is True - assert response.setup_version is not None + assert response.result.success is True + assert response.result.setup_version is not None mock_job_manager.create_config_setup_instance_job.assert_called_once() mock_job_manager.generate_config_setup_module_response.assert_called_once_with("test-config-job-id") @@ -510,13 +516,13 @@ async def test_config_setup_module_job_creation_fails(self, module_servicer, fak """Test config setup when job creation fails.""" mock_job_manager.create_config_setup_instance_job = AsyncMock(return_value=None) - setup_version = setup_pb2.SetupVersion( + setup_version = SetupVersion( id="version-123", setup_id="setup-123", content=json_format.ParseDict({"existing": "config"}, struct_pb2.Struct()), ) - request = lifecycle_pb2.ConfigSetupModuleRequest( + request = module_dto_pb2.ConfigSetupModuleRequest( mission_id="mission-456", setup_version=setup_version, content=json_format.ParseDict({"new": "config"}, struct_pb2.Struct()), @@ -524,7 +530,7 @@ async def test_config_setup_module_job_creation_fails(self, module_servicer, fak response = await module_servicer.ConfigSetupModule(request, fake_context) - assert response.success is False + assert response.result.success is False assert fake_context.get_code() == grpc.StatusCode.NOT_FOUND assert "Failed to create module instance" in fake_context.get_details() @@ -532,13 +538,13 @@ async def test_config_setup_module_job_creation_fails(self, module_servicer, fak async def test_config_setup_module_no_setup_data(self, module_servicer, fake_context): """Test config setup when setup data creation fails.""" with patch.object(MockModule, "create_setup_model", return_value=None): - setup_version = setup_pb2.SetupVersion( + setup_version = SetupVersion( id="version-123", setup_id="setup-123", content=json_format.ParseDict({"existing": "config"}, struct_pb2.Struct()), ) - request = lifecycle_pb2.ConfigSetupModuleRequest( + request = module_dto_pb2.ConfigSetupModuleRequest( mission_id="mission-456", setup_version=setup_version, content=json_format.ParseDict({"new": "config"}, struct_pb2.Struct()), @@ -551,13 +557,13 @@ async def test_config_setup_module_no_setup_data(self, module_servicer, fake_con async def test_config_setup_module_no_config_setup_data(self, module_servicer, fake_context): """Test config setup when config setup data creation fails.""" with patch.object(MockModule, "create_config_setup_model", return_value=None): - setup_version = setup_pb2.SetupVersion( + setup_version = SetupVersion( id="version-123", setup_id="setup-123", content=json_format.ParseDict({"existing": "config"}, struct_pb2.Struct()), ) - request = lifecycle_pb2.ConfigSetupModuleRequest( + request = module_dto_pb2.ConfigSetupModuleRequest( mission_id="mission-456", setup_version=setup_version, content=json_format.ParseDict({"new": "config"}, struct_pb2.Struct()), diff --git a/tests/mixins/test_chat_history_mixin.py b/tests/mixins/test_chat_history_mixin.py index d140914f..87f77637 100644 --- a/tests/mixins/test_chat_history_mixin.py +++ b/tests/mixins/test_chat_history_mixin.py @@ -22,7 +22,7 @@ def _make_context(mission_id: str = "test_mission") -> MagicMock: ctx = MagicMock() ctx.session.mission_id = mission_id ctx.storage = AsyncMock() - ctx.storage.read = AsyncMock(return_value=None) + ctx.storage.get = AsyncMock(return_value=None) ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) ctx.storage.update = AsyncMock(return_value=MagicMock(spec=StorageRecord)) return ctx @@ -44,28 +44,28 @@ async def test_load_reads_storage_once_then_caches(self) -> None: mixin = _ConcreteMixin() ctx = _make_context() existing = _storage_record_with_history([{"role": "user", "content": "hello"}]) - ctx.storage.read = AsyncMock(return_value=existing) + ctx.storage.get = AsyncMock(return_value=existing) first = await mixin.load_chat_history(ctx) second = await mixin.load_chat_history(ctx) if first is not second: pytest.fail("Expected cached ChatHistory object on second call") - ctx.storage.read.assert_awaited_once() + ctx.storage.get.assert_awaited_once() @pytest.mark.asyncio async def test_load_returns_empty_on_cache_miss(self) -> None: """When storage has no record, returns empty ChatHistory and caches it.""" mixin = _ConcreteMixin() ctx = _make_context() - ctx.storage.read = AsyncMock(return_value=None) + ctx.storage.get = AsyncMock(return_value=None) history = await mixin.load_chat_history(ctx) if len(history.messages) != 0: pytest.fail(f"Expected empty messages, got {len(history.messages)}") await mixin.load_chat_history(ctx) - ctx.storage.read.assert_awaited_once() + ctx.storage.get.assert_awaited_once() @pytest.mark.asyncio async def test_different_missions_cached_independently(self) -> None: @@ -157,7 +157,7 @@ async def test_preexisting_record_uses_update_from_start(self) -> None: mixin = _ConcreteMixin() ctx = _make_context() existing = _storage_record_with_history([{"role": "user", "content": "old"}]) - ctx.storage.read = AsyncMock(return_value=existing) + ctx.storage.get = AsyncMock(return_value=existing) await mixin.load_chat_history(ctx) await mixin.append_chat_history_message(ctx, BaseRole.ASSISTANT, "new") @@ -179,7 +179,7 @@ async def test_append_accumulates_messages_in_cache(self) -> None: history = await mixin.load_chat_history(ctx) if len(history.messages) != 3: pytest.fail(f"Expected 3 messages in cache, got {len(history.messages)}") - ctx.storage.read.assert_awaited_once() + ctx.storage.get.assert_awaited_once() class TestBatchingBehavior: @@ -308,7 +308,7 @@ def _make_mock_context() -> MagicMock: ctx = MagicMock() ctx.session.mission_id = "test_mission" ctx.storage = AsyncMock() - ctx.storage.read = AsyncMock(return_value=None) + ctx.storage.get = AsyncMock(return_value=None) ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) ctx.storage.update = AsyncMock(return_value=MagicMock(spec=StorageRecord)) return ctx diff --git a/tests/mixins/test_file_history_mixin.py b/tests/mixins/test_file_history_mixin.py index 5d987b9a..2e9e2804 100644 --- a/tests/mixins/test_file_history_mixin.py +++ b/tests/mixins/test_file_history_mixin.py @@ -22,7 +22,7 @@ def _make_context(mission_id: str = "test_mission") -> MagicMock: ctx = MagicMock() ctx.session.mission_id = mission_id ctx.storage = AsyncMock() - ctx.storage.read = AsyncMock(return_value=None) + ctx.storage.get = AsyncMock(return_value=None) ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) ctx.storage.update = AsyncMock(return_value=MagicMock(spec=StorageRecord)) return ctx @@ -49,14 +49,14 @@ async def test_load_reads_storage_once_then_caches(self) -> None: mixin = _ConcreteMixin() ctx = _make_context() existing = _storage_record_with_history([{"file_id": "f1", "name": "a.txt"}]) - ctx.storage.read = AsyncMock(return_value=existing) + ctx.storage.get = AsyncMock(return_value=existing) first = await mixin.load_file_history(ctx) second = await mixin.load_file_history(ctx) if first is not second: pytest.fail("Expected cached FileHistory object on second call") - ctx.storage.read.assert_awaited_once() + ctx.storage.get.assert_awaited_once() @pytest.mark.asyncio async def test_load_returns_empty_on_cache_miss(self) -> None: @@ -69,7 +69,7 @@ async def test_load_returns_empty_on_cache_miss(self) -> None: if len(history.files) != 0: pytest.fail(f"Expected empty files, got {len(history.files)}") await mixin.load_file_history(ctx) - ctx.storage.read.assert_awaited_once() + ctx.storage.get.assert_awaited_once() @pytest.mark.asyncio async def test_different_missions_cached_independently(self) -> None: @@ -122,7 +122,7 @@ async def test_preexisting_record_uses_update_from_start(self) -> None: mixin = _ConcreteMixin() ctx = _make_context() existing = _storage_record_with_history([{"file_id": "f1", "name": "old.txt"}]) - ctx.storage.read = AsyncMock(return_value=existing) + ctx.storage.get = AsyncMock(return_value=existing) await mixin.load_file_history(ctx) await mixin.append_files_history(ctx, _make_files(1)) @@ -143,7 +143,7 @@ async def test_append_accumulates_files_in_cache(self) -> None: history = await mixin.load_file_history(ctx) if len(history.files) != 5: pytest.fail(f"Expected 5 files in cache, got {len(history.files)}") - ctx.storage.read.assert_awaited_once() + ctx.storage.get.assert_awaited_once() class TestBatchingBehavior: @@ -295,7 +295,7 @@ def _make_mock_context() -> MagicMock: ctx = MagicMock() ctx.session.mission_id = "test_mission" ctx.storage = AsyncMock() - ctx.storage.read = AsyncMock(return_value=None) + ctx.storage.get = AsyncMock(return_value=None) ctx.storage.upsert = AsyncMock(return_value=MagicMock(spec=StorageRecord)) ctx.storage.update = AsyncMock(return_value=MagicMock(spec=StorageRecord)) return ctx diff --git a/tests/modules/_test_base_module.py b/tests/modules/_test_base_module.py index 01b18e5e..a9e0bbe6 100644 --- a/tests/modules/_test_base_module.py +++ b/tests/modules/_test_base_module.py @@ -5,7 +5,9 @@ import pytest -from digitalkin.models.module import ModuleStatus, StrategyConfig +from digitalkin import ModuleContext +from digitalkin.models.module.base_types import SetupModelT +from digitalkin.models.module.module import ModuleStatus, StrategyConfig from digitalkin.modules._base_module import BaseModule @@ -48,6 +50,12 @@ async def _cleanup(self) -> None: msg = "Test cleanup error" raise Exception(msg) + async def initialize(self, context: ModuleContext, setup_data: SetupModelT) -> None: + pass + + async def cleanup(self) -> None: + pass + @pytest.fixture def mock_strategy_config() -> MagicMock: diff --git a/tests/modules/test_format_methods.py b/tests/modules/test_format_methods.py index 76f2160a..0b88e6f1 100644 --- a/tests/modules/test_format_methods.py +++ b/tests/modules/test_format_methods.py @@ -14,8 +14,8 @@ from digitalkin.models.module.base_types import DataModel, DataTrigger from digitalkin.models.module.module_types import SetupModel from digitalkin.models.module.select_schema import SelectSchema +from digitalkin.models.services.cost import CostConfig, CostType from digitalkin.modules._base_module import BaseModule -from digitalkin.services.cost.cost_strategy import CostConfig from digitalkin.utils.package_discover import ModuleDiscoverer @@ -277,8 +277,8 @@ async def test_cost_format_empty(self) -> None: async def test_cost_format_with_config(self) -> None: """Returns cost schema when config is present.""" cost_config = CostConfig( - cost_name="api_call", - cost_type="API_CALL", + name="api_call", + type=CostType.API_CALL, description="Cost per API call", unit="USD", rate=0.01, @@ -297,8 +297,8 @@ class CostModule(BaseModule): async def test_cost_format_llm(self) -> None: """LLM format returns json_schema + ui_schema.""" cost_config = CostConfig( - cost_name="tokens", - cost_type="TOKEN_INPUT", + name="tokens", + type=CostType.TOKEN_INPUT, description="Cost per token", unit="USD", rate=0.001, diff --git a/tests/modules/test_tool_cache.py b/tests/modules/test_tool_cache.py index 968c133d..d8e4afb8 100644 --- a/tests/modules/test_tool_cache.py +++ b/tests/modules/test_tool_cache.py @@ -5,21 +5,22 @@ import pytest from digitalkin.models.module.setup_types import SetupModel -from digitalkin.models.module.tool_cache import ToolCache, ToolDefinition, ToolModuleInfo, ToolParameter +from digitalkin.models.module.tool_cache import ToolCache, ToolModuleInfo, ToolDefinition, ToolParameter from digitalkin.models.module.tool_reference import ToolReference, ToolSelection -from digitalkin.models.services.registry import ModuleInfo, RegistryModuleType, SetupInfo +from digitalkin.models.services.setup import SetupInfo +from digitalkin.services.registry import ModuleType, ModuleInfo @pytest.fixture def sample_tool_module_info() -> ToolModuleInfo: """Create a sample ToolModuleInfo for testing.""" return ToolModuleInfo( - module_id="tool-123", - module_type=RegistryModuleType.TOOL, + id="tool-123", + type=ModuleType.TOOL, address="localhost", port=50051, version="1.0.0", - module_name="TestTool", + name="TestTool", documentation="Test tool documentation", setup_id="setup-123", tool_name="TestTool", @@ -39,12 +40,12 @@ def sample_tool_module_info() -> ToolModuleInfo: def sample_tool_module_info_2() -> ToolModuleInfo: """Create a second sample ToolModuleInfo for testing.""" return ToolModuleInfo( - module_id="tool-456", - module_type=RegistryModuleType.TOOL, + id="tool-456", + type=ModuleType.TOOL, address="localhost", port=50052, version="2.0.0", - module_name="AnotherTool", + name="AnotherTool", documentation="Another test tool", setup_id="setup-456", tool_name="AnotherTool", @@ -258,13 +259,13 @@ class TestSetup(SetupModel): name="Test Setup", module_id="tool-123", ) - mock_registry.discover_by_id.return_value = ModuleInfo( - module_id="tool-123", - module_type=RegistryModuleType.TOOL, + mock_registry.get.return_value = ModuleInfo( + id="tool-123", + type=ModuleType.TOOL, address="localhost", port=50051, version="1.0.0", - module_name="TestTool", + name="TestTool", documentation="Test tool documentation", ) @@ -276,7 +277,7 @@ class TestSetup(SetupModel): await setup.build_tool_cache(mock_registry, mock_communication) mock_registry.get_setup.assert_called_once_with("setup-123") - mock_registry.discover_by_id.assert_called_once_with("tool-123") + mock_registry.get.assert_called_once_with("tool-123") assert len(setup.resolved_tools) == 1 @pytest.mark.asyncio @@ -297,13 +298,13 @@ class TestSetup(SetupModel): name="Test Setup", module_id="tool-123", ) - mock_registry.discover_by_id.return_value = ModuleInfo( - module_id="tool-123", - module_type=RegistryModuleType.TOOL, + mock_registry.get.return_value = ModuleInfo( + id="tool-123", + type=ModuleType.TOOL, address="localhost", port=50051, version="1.0.0", - module_name="TestTool", + name="TestTool", documentation="Test tool documentation", ) @@ -352,7 +353,7 @@ class TestSetup(SetupModel): await restored_setup.build_tool_cache(mock_registry, mock_communication) mock_registry.get_setup.assert_not_called() - mock_registry.discover_by_id.assert_not_called() + mock_registry.get.assert_not_called() @pytest.mark.asyncio async def test_multiple_tools_cache_behavior( @@ -375,24 +376,24 @@ class TestSetup(SetupModel): if setup_id == "setup-123" else SetupInfo(setup_id="setup-456", name="Tool B", module_id="tool-456") ) - mock_registry.discover_by_id.side_effect = lambda module_id: ( + mock_registry.get.side_effect = lambda module_id: ( ModuleInfo( - module_id="tool-123", - module_type=RegistryModuleType.TOOL, + id="tool-123", + type=ModuleType.TOOL, address="localhost", port=50051, version="1.0.0", - module_name="ToolA", + name="ToolA", documentation="Tool A", ) if module_id == "tool-123" else ModuleInfo( - module_id="tool-456", - module_type=RegistryModuleType.TOOL, + id="tool-456", + type=ModuleType.TOOL, address="localhost", port=50052, version="1.0.0", - module_name="ToolB", + name="ToolB", documentation="Tool B", ) ) @@ -413,7 +414,7 @@ class TestSetup(SetupModel): await setup.build_tool_cache(mock_registry, mock_communication) mock_registry.get_setup.assert_not_called() - mock_registry.discover_by_id.assert_not_called() + mock_registry.get.assert_not_called() assert len(setup.resolved_tools) == 2 @pytest.mark.asyncio @@ -440,13 +441,13 @@ class TestSetup(SetupModel): name="Tool B", module_id="tool-456", ) - mock_registry.discover_by_id.return_value = ModuleInfo( - module_id="tool-456", - module_type=RegistryModuleType.TOOL, + mock_registry.get.return_value = ModuleInfo( + id="tool-456", + type=ModuleType.TOOL, address="localhost", port=50052, version="1.0.0", - module_name="ToolB", + name="ToolB", documentation="Tool B", ) @@ -493,7 +494,7 @@ def test_slugify_camelcase(self) -> None: def test_slug_property_uses_tool_name(self) -> None: """Test slug property returns slugified tool_name.""" info = ToolModuleInfo( - module_id="m1", + id="m1", setup_id="setup-abc", tool_name="Google Search", ) @@ -502,7 +503,7 @@ def test_slug_property_uses_tool_name(self) -> None: def test_slug_no_setup_id(self) -> None: """Test slug does not contain setup_id.""" info = ToolModuleInfo( - module_id="m1", + id="m1", setup_id="setup-abc-123", tool_name="My Tool", ) @@ -519,7 +520,7 @@ def test_different_setup_ids_coexist(self, sample_tool_module_info: ToolModuleIn cache.add(sample_tool_module_info) other = ToolModuleInfo( - module_id="tool-other", + id="tool-other", setup_id="setup-other", tool_name="TestTool", tools=[], diff --git a/tests/modules/test_tool_reference.py b/tests/modules/test_tool_reference.py index 0ed21a7a..a323de0c 100644 --- a/tests/modules/test_tool_reference.py +++ b/tests/modules/test_tool_reference.py @@ -9,28 +9,30 @@ import pytest from pydantic import BaseModel, Field, TypeAdapter, ValidationError +from digitalkin.models.module import ToolDefinition, ToolParameter, ToolModuleInfo from digitalkin.models.module.setup_types import SetupModel -from digitalkin.models.module.tool_cache import ToolDefinition, ToolModuleInfo, ToolParameter -from digitalkin.models.module.tool_reference import ToolReference, ToolSelection, tool_reference_input -from digitalkin.models.services.registry import ( - ModuleInfo, - RegistryModuleStatus, - RegistryModuleType, - SetupInfo, +from digitalkin.models.module.tool_reference import ( + ToolReference, + ToolSelection, tool_reference_input, ) +from digitalkin.models.services.setup import SetupInfo +from digitalkin.services.registry import ModuleStatus, ModuleType, ModuleInfo from digitalkin.services.registry import RegistryStrategy class FakeRegistry(RegistryStrategy): """Fake registry for testing tool resolution.""" - def __init__(self, modules: dict[str, ModuleInfo] | None = None) -> None: + def __init__(self, mission_id: str = None, setup_id: str = None, setup_version_id: str = None, modules: dict[str, ModuleInfo] | None = None) -> \ + None: + super().__init__(mission_id, setup_id, setup_version_id) self._modules = modules or {} self._setups: dict[str, SetupInfo] = {} self._search_results: dict[str, list[ModuleInfo]] = {} + def add_module(self, info: ModuleInfo) -> None: - self._modules[info.module_id] = info + self._modules[info.id] = info def add_setup(self, setup_id: str, module_id: str, name: str = "") -> None: self._setups[setup_id] = SetupInfo( @@ -42,7 +44,7 @@ def add_setup(self, setup_id: str, module_id: str, name: str = "") -> None: def add_search_result(self, tag: str, results: list[ModuleInfo]) -> None: self._search_results[tag] = results - async def discover_by_id(self, module_id: str) -> ModuleInfo | None: + async def get(self, module_id: str) -> ModuleInfo | None: return self._modules.get(module_id) async def get_setup(self, setup_id: str) -> SetupInfo | None: @@ -51,7 +53,7 @@ async def get_setup(self, setup_id: str) -> SetupInfo | None: async def search( self, name: str | None = None, - module_type: str | None = None, + module_type: ModuleType | None = None, organization_id: str | None = None, ) -> list[ModuleInfo]: if name and name in self._search_results: @@ -70,8 +72,8 @@ async def register( ) -> ModuleInfo | None: return None - async def heartbeat(self, module_id: str) -> RegistryModuleStatus: - return RegistryModuleStatus.ACTIVE + def heartbeat(self, module_id: str) -> ModuleStatus: + return ModuleStatus.ACTIVE async def deregister(self, module_id: str) -> bool: return True @@ -108,12 +110,12 @@ def create_tool_module_info( ) -> ToolModuleInfo: """Create a ToolModuleInfo for testing.""" return ToolModuleInfo( - module_id=module_id, - module_type=RegistryModuleType.TOOL, + id=module_id, + type=ModuleType.TOOL, address="localhost", port=port, version="1.0.0", - module_name=name, + name=name, setup_id=setup_id, tool_name=tool_name, tools=[ @@ -131,36 +133,39 @@ def create_tool_module_info( @pytest.fixture def search_tool_info() -> ModuleInfo: return ModuleInfo( - module_id="tool-search-001", - module_type=RegistryModuleType.TOOL, + id="tool-search-001", + type=ModuleType.TOOL, address="localhost", port=50051, version="1.0.0", - module_name="SearchTool", + name="SearchTool", + status=ModuleStatus.ACTIVE ) @pytest.fixture def analyzer_tool_info() -> ModuleInfo: return ModuleInfo( - module_id="tool-analyzer-002", - module_type=RegistryModuleType.TOOL, + id="tool-analyzer-002", + type=ModuleType.TOOL, address="localhost", port=50052, version="2.0.0", - module_name="AnalyzerTool", + name="AnalyzerTool", + status=ModuleStatus.ACTIVE ) @pytest.fixture def writer_tool_info() -> ModuleInfo: return ModuleInfo( - module_id="tool-writer-003", - module_type=RegistryModuleType.TOOL, + id="tool-writer-003", + type=ModuleType.TOOL, address="localhost", port=50053, version="1.5.0", - module_name="WriterTool", + name="WriterTool", + status=ModuleStatus.ACTIVE ) @@ -239,7 +244,7 @@ async def test_selected_tools_resolve_by_setup_id( result = await ref.resolve(registry, communication) assert len(result) == 1 - assert result[0].module_id == "tool-search-001" + assert result[0].id == "tool-search-001" assert len(result[0].tools) == 1 assert result[0].tools[0].name == "search" @@ -258,7 +263,7 @@ async def test_multiple_selected_tools_resolve( result = await ref.resolve(registry, communication) assert len(result) == 2 - module_ids = {r.module_id for r in result} + module_ids = {r.id for r in result} assert "tool-search-001" in module_ids assert "tool-analyzer-002" in module_ids @@ -305,7 +310,7 @@ class ArchetypeSetup(SetupModel): assert len(setup.resolved_tools) == 1 tool_info = next(iter(setup.resolved_tools.values())) - assert tool_info.module_id == "tool-search-001" + assert tool_info.id == "tool-search-001" @pytest.mark.asyncio async def test_multiple_tool_references_resolved( @@ -329,7 +334,7 @@ class ArchetypeSetup(SetupModel): await setup.build_tool_cache(registry, communication) assert len(setup.resolved_tools) == 2 - module_ids = {info.module_id for info in setup.resolved_tools.values()} + module_ids = {info.id for info in setup.resolved_tools.values()} assert "tool-search-001" in module_ids assert "tool-analyzer-002" in module_ids @@ -387,7 +392,7 @@ class ArchetypeSetup(SetupModel): assert len(setup.resolved_tools) == 1 tool_info = next(iter(setup.resolved_tools.values())) - assert tool_info.module_id == "tool-search-001" + assert tool_info.id == "tool-search-001" @pytest.mark.asyncio async def test_deeply_nested_tool_resolved( @@ -414,7 +419,7 @@ class ArchetypeSetup(SetupModel): assert len(setup.resolved_tools) == 1 tool_info = next(iter(setup.resolved_tools.values())) - assert tool_info.module_id == "tool-analyzer-002" + assert tool_info.id == "tool-analyzer-002" @pytest.mark.asyncio async def test_list_of_tool_references_resolved( @@ -439,7 +444,7 @@ class ArchetypeSetup(SetupModel): assert len(setup.tools) == 2 assert len(setup.resolved_tools) == 2 - module_ids = {info.module_id for info in setup.resolved_tools.values()} + module_ids = {info.id for info in setup.resolved_tools.values()} assert "tool-search-001" in module_ids assert "tool-analyzer-002" in module_ids @@ -475,7 +480,7 @@ class ArchetypeSetup(SetupModel): await setup.build_tool_cache(registry, communication) assert len(setup.resolved_tools) == 2 - module_ids = {info.module_id for info in setup.resolved_tools.values()} + module_ids = {info.id for info in setup.resolved_tools.values()} assert "tool-search-001" in module_ids assert "tool-writer-003" in module_ids @@ -501,7 +506,7 @@ class ArchetypeSetup(SetupModel): await setup.build_tool_cache(registry, communication) assert len(setup.resolved_tools) == 2 - module_ids = {info.module_id for info in setup.resolved_tools.values()} + module_ids = {info.id for info in setup.resolved_tools.values()} assert "tool-search-001" in module_ids assert "tool-analyzer-002" in module_ids @@ -530,7 +535,7 @@ class ArchetypeSetup(SetupModel): await setup.build_tool_cache(registry, communication) assert len(setup.resolved_tools) == 2 - module_ids = {info.module_id for info in setup.resolved_tools.values()} + module_ids = {info.id for info in setup.resolved_tools.values()} assert "tool-search-001" in module_ids assert "tool-writer-003" in module_ids @@ -575,7 +580,7 @@ class ResearchArchetypeSetup(SetupModel): # All tools resolved correctly assert len(setup.resolved_tools) == 3 - module_ids = {info.module_id for info in setup.resolved_tools.values()} + module_ids = {info.id for info in setup.resolved_tools.values()} assert "tool-search-001" in module_ids assert "tool-writer-003" in module_ids assert "tool-analyzer-002" in module_ids @@ -604,7 +609,7 @@ class ArchetypeSetup(SetupModel): # Only existing_tool resolved assert len(setup.resolved_tools) == 1 tool_info = next(iter(setup.resolved_tools.values())) - assert tool_info.module_id == "tool-search-001" + assert tool_info.id == "tool-search-001" class TestToolReferenceJsonSchema: diff --git a/tests/performances/load_taskiq_testing.py b/tests/performances/load_taskiq_testing.py index f8a882c6..1a60e8e6 100644 --- a/tests/performances/load_taskiq_testing.py +++ b/tests/performances/load_taskiq_testing.py @@ -11,8 +11,8 @@ import grpc import psutil -from agentic_mesh_protocol.module.v1 import information_pb2, lifecycle_pb2, module_service_pb2_grpc -from agentic_mesh_protocol.module_registry.v1 import discover_pb2, module_registry_service_pb2_grpc +from agentic_mesh_protocol.module.v1 import module_dto_pb2, module_service_pb2_grpc +from agentic_mesh_protocol.registry.v1 import registry_dto_pb2, registry_service_pb2_grpc from google.protobuf import json_format from hdrh.histogram import HdrHistogram from pydantic import BaseModel, Field, create_model @@ -105,7 +105,7 @@ def _create_model_from_schema( for schema_item in field_info["anyOf"]: if "type" in schema_item: - item_type = schema_item.get("type", "string") + item_type = schema_item.list("type", "string") type_class = TYPE_MAPPING.get(item_type, Any) union_types.append(type_class) @@ -116,7 +116,7 @@ def _create_model_from_schema( field_type = Any # Handle array type - elif field_info.get("type") == "array" and "items" in field_info: + elif field_info.list("type") == "array" and "items" in field_info: items = field_info["items"] if "$ref" in items: ref_path = items["$ref"] @@ -136,19 +136,19 @@ def _create_model_from_schema( else: item_type = Any else: - item_type_str = items.get("type", "string") + item_type_str = items.list("type", "string") item_type = TYPE_MAPPING.get(item_type_str, Any) field_type = list[item_type] else: # Handle regular types - field_type_str = field_info.get("type", "string") + field_type_str = field_info.list("type", "string") field_type = TYPE_MAPPING.get(field_type_str, Any) # Create Field with metadata - field_title = field_info.get("title", field_name) - field_description = field_info.get("description", "") - field_default = field_info.get("default") + field_title = field_info.list("title", field_name) + field_description = field_info.list("description", "") + field_default = field_info.list("default") # Handle discriminator fields field_kwargs: dict[Any, Any] = {} @@ -248,7 +248,7 @@ def dict_to_pydantic_cached( async def discover_module( registry_channel: grpc.aio.Channel, module_name: str -) -> discover_pb2.DiscoverInfoResponse | None: +) -> registry_dto_pb2.GetModuleResponse | None: """Discover a module by name from the registry. Args: @@ -259,10 +259,10 @@ async def discover_module( Module information or None if not found """ # Create registry service stub - registry_stub = module_registry_service_pb2_grpc.ModuleRegistryServiceStub(registry_channel) + registry_stub = registry_service_pb2_grpc.RegistryServiceStub(registry_channel) # Create discover request - request = discover_pb2.DiscoverSearchRequest(name=module_name) + request = registry_dto_pb2.DiscoverSearchRequest(name=module_name) try: # Send request to registry @@ -294,9 +294,9 @@ async def get_module_schemas( Tuple of (input_class, output_class, setup_class) Pydantic models """ # Create requests for each schema - input_request = information_pb2.GetModuleInputRequest(module_id=module_id) - output_request = information_pb2.GetModuleOutputRequest(module_id=module_id) - setup_request = information_pb2.GetModuleSetupRequest(module_id=module_id) + input_request = module_dto_pb2.GetModuleInputRequest(module_id=module_id) + output_request = module_dto_pb2.GetModuleOutputRequest(module_id=module_id) + setup_request = module_dto_pb2.GetModuleSetupRequest(module_id=module_id) # Get schemas from module input_response = await module_stub.GetModuleInput(input_request) @@ -333,7 +333,7 @@ async def worker( "user_prompt": "Give me details about agentic mesh current advancement", } ) - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( input=input_data.model_dump(), setup_id=setup_id, mission_id=mission_id, @@ -378,7 +378,7 @@ async def worker( async def fire_one( module_stub: Any, - request: lifecycle_pb2.StartModuleRequest, + request: module_dto_pb2.StartModuleRequest, ) -> float: """Send a single StartModule RPC and return latency.""" start = time.perf_counter() @@ -422,7 +422,7 @@ async def worker( input_data = input_class( payload={"payload_type": "message", "user_prompt": "Give me details about agentic mesh current advancement"} ) - request = lifecycle_pb2.StartModuleRequest( + request = module_dto_pb2.StartModuleRequest( input=input_data.model_dump(), setup_id=setup_id, mission_id=mission_id ) while True: @@ -465,7 +465,7 @@ async def worker( async def burst_load( parallelism: int, module_stub: Any, - request: lifecycle_pb2.StartModuleRequest, + request: module_dto_pb2.StartModuleRequest, ) -> list[float]: """Burst load: fire `parallelism` requests simultaneously and gather latencies.""" coros = [fire_one(module_stub, request) for _ in range(parallelism)] @@ -501,7 +501,7 @@ async def main() -> None: logger.error("Module not found") return module_stub = module_service_pb2_grpc.ModuleServiceStub(grpc.aio.insecure_channel(args.target)) - input_class, output_class, _ = await get_module_schemas(module_stub, module.module_id) + input_class, output_class, _ = await get_module_schemas(module_stub, module.id) # Pre-build shared request for burst setup_id = "setups:cortex_setup" @@ -512,7 +512,7 @@ async def main() -> None: "user_prompt": "100000", } ) - shared_request = lifecycle_pb2.StartModuleRequest( + shared_request = module_dto_pb2.StartModuleRequest( input=input_data.model_dump(), setup_id=setup_id, mission_id=mission_id ) diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/cost/mock_cost_servicer.py b/tests/services/cost/mock_cost_servicer.py index 005f703d..623122c1 100644 --- a/tests/services/cost/mock_cost_servicer.py +++ b/tests/services/cost/mock_cost_servicer.py @@ -3,11 +3,12 @@ from typing import Any import grpc -from agentic_mesh_protocol.cost.v1 import cost_pb2, cost_service_pb2_grpc +from agentic_mesh_protocol.cost.v1 import cost_messages_pb2, cost_service_pb2_grpc, cost_dto_pb2 +from agentic_mesh_protocol.pagination.v1 import bulk_pb2, pagination_pb2 from pydantic import ValidationError from digitalkin.logger import logger -from digitalkin.services.cost.cost_strategy import CostData, CostType +from digitalkin.models.services.cost import CostType, CostData class MockCostServicer(cost_service_pb2_grpc.CostServiceServicer): @@ -39,7 +40,7 @@ def _validate_and_store_cost(self, cost_dict: dict[str, Any]) -> None: self.costs[mission_id].append(cost_data.model_dump()) logger.debug(f"Stored cost: {cost_data.name} for mission {mission_id}") - def _cost_dict_to_proto(self, cost_dict: dict[str, Any]) -> cost_pb2.Cost: + def _cost_dict_to_proto(self, cost_dict: dict[str, Any]) -> cost_messages_pb2.Cost: """Convert a cost dictionary to a proto Cost message. Args: @@ -48,29 +49,18 @@ def _cost_dict_to_proto(self, cost_dict: dict[str, Any]) -> cost_pb2.Cost: Returns: cost_pb2.Cost: Proto cost message """ - # Convert Python CostType enum to protobuf enum - python_to_proto_cost_type = { - CostType.TOKEN_INPUT: cost_pb2.TOKEN_INPUT, - CostType.TOKEN_OUTPUT: cost_pb2.TOKEN_OUTPUT, - CostType.API_CALL: cost_pb2.API_CALL, - CostType.STORAGE: cost_pb2.STORAGE, - CostType.TIME: cost_pb2.TIME, - CostType.OTHER: cost_pb2.OTHER, - } - proto_cost_type = python_to_proto_cost_type.get(cost_dict["cost_type"], cost_pb2.OTHER) - - return cost_pb2.Cost( + return cost_messages_pb2.Cost( cost=cost_dict["cost"], name=cost_dict["name"], unit=cost_dict["unit"], - cost_type=proto_cost_type, + type=cost_dict["type"].to_proto(), mission_id=cost_dict["mission_id"], rate=cost_dict["rate"], quantity=cost_dict["quantity"], setup_version_id=cost_dict["setup_version_id"], ) - def AddCost(self, request: cost_pb2.AddCostRequest, context: grpc.ServicerContext) -> cost_pb2.AddCostResponse: + def CreateCost(self, request: cost_dto_pb2.CreateCostRequest, context: grpc.ServicerContext) -> cost_dto_pb2.CreateCostResponse: """Add a cost record to the mock database. Args: @@ -85,128 +75,75 @@ def AddCost(self, request: cost_pb2.AddCostRequest, context: grpc.ServicerContex if not request.name: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Cost name is required") - return cost_pb2.AddCostResponse(success=False) + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return cost_pb2.AddCostResponse(success=False) + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) if request.quantity <= 0: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Quantity must be positive") - return cost_pb2.AddCostResponse(success=False) + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) if request.rate < 0: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Rate cannot be negative") - return cost_pb2.AddCostResponse(success=False) + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) # Validate cost type # Note: Protobuf enum values are integers, not strings # Validate that cost_type is one of the valid * enum values - valid_values = [ - cost_pb2.TOKEN_INPUT, - cost_pb2.TOKEN_OUTPUT, - cost_pb2.API_CALL, - cost_pb2.STORAGE, - cost_pb2.TIME, - cost_pb2.OTHER, - ] - if request.cost_type not in valid_values: + cost_type = CostType.from_proto(request.type) + if cost_type not in CostType: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(f"Invalid cost type: {request.cost_type}") - return cost_pb2.AddCostResponse(success=False) - - # Convert protobuf cost_type enum to Python CostType enum - # Protobuf enums: TOKEN_INPUT=1, TOKEN_OUTPUT=2, etc. - # Python enums: TOKEN_INPUT, TOKEN_OUTPUT, etc. - proto_to_python_cost_type = { - cost_pb2.TOKEN_INPUT: CostType.TOKEN_INPUT, - cost_pb2.TOKEN_OUTPUT: CostType.TOKEN_OUTPUT, - cost_pb2.API_CALL: CostType.API_CALL, - cost_pb2.STORAGE: CostType.STORAGE, - cost_pb2.TIME: CostType.TIME, - cost_pb2.OTHER: CostType.OTHER, - } - python_cost_type = proto_to_python_cost_type.get(request.cost_type, CostType.OTHER) - - # Create cost dictionary - cost_dict = { - "cost": request.cost, - "name": request.name, - "unit": request.unit, - "cost_type": python_cost_type, - "mission_id": request.mission_id, - "rate": request.rate, - "quantity": request.quantity, - "setup_version_id": request.setup_version_id, - } + context.set_details(f"Invalid cost type: {cost_type}") + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) + + cost_data = CostData( + cost=request.cost, + name=request.name, + unit=request.unit, + type=cost_type, + mission_id=request.mission_id, + rate=request.rate, + quantity=request.quantity, + setup_version_id=request.setup_version_id + ) # Validate and store - self._validate_and_store_cost(cost_dict) + self._validate_and_store_cost(cost_data.dict()) logger.info(f"Added cost: {request.name} for mission {request.mission_id}") - return cost_pb2.AddCostResponse(success=True) + + # Create cost proto with proper type conversion + cost_dict = cost_data.model_dump() + cost_dict["type"] = cost_type.to_proto() + + result = cost_messages_pb2.CostResult(success=True, cost=cost_messages_pb2.Cost(**cost_dict)) + return cost_dto_pb2.CreateCostResponse(result=result) except ValidationError as e: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details(f"Validation error: {e!s}") logger.error(f"Validation error in AddCost: {e}") - return cost_pb2.AddCostResponse(success=False) + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in AddCost: {e}", exc_info=True) - return cost_pb2.AddCostResponse(success=False) - - def GetCost(self, request: cost_pb2.GetCostRequest, context: grpc.ServicerContext) -> cost_pb2.GetCostResponse: - """Get costs by name for a specific mission. - - Args: - request: GetCostRequest containing name and mission_id - context: gRPC context - - Returns: - GetCostResponse: Response containing matching costs - """ - try: - if not request.name: - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details("Cost name is required") - return cost_pb2.GetCostResponse(costs=[]) - - if not request.mission_id: - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details("Mission ID is required") - return cost_pb2.GetCostResponse(costs=[]) - - # Get costs for this mission - mission_costs = self.costs.get(request.mission_id, []) - - # Filter by name - matching_costs = [c for c in mission_costs if c["name"] == request.name] - - if not matching_costs: - logger.debug(f"No costs found with name '{request.name}' for mission {request.mission_id}") - return cost_pb2.GetCostResponse(costs=[]) - - # Convert to proto messages - cost_protos = [self._cost_dict_to_proto(cost) for cost in matching_costs] - - logger.info( - f"Retrieved {len(matching_costs)} costs with name '{request.name}' for mission {request.mission_id}" - ) - return cost_pb2.GetCostResponse(costs=cost_protos) - - except Exception as e: - context.set_code(grpc.StatusCode.INTERNAL) - context.set_details(f"Internal error: {e!s}") - logger.error(f"Error in GetCost: {e}", exc_info=True) - return cost_pb2.GetCostResponse(costs=[]) + result = cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return cost_dto_pb2.CreateCostResponse(result=result) - def GetCosts(self, request: cost_pb2.GetCostsRequest, context: grpc.ServicerContext) -> cost_pb2.GetCostsResponse: + def ListCosts(self, request: cost_dto_pb2.ListCostsRequest, context: grpc.ServicerContext) -> cost_dto_pb2.ListCostsResponse: """Get costs filtered by names and/or cost types. Args: @@ -216,14 +153,19 @@ def GetCosts(self, request: cost_pb2.GetCostsRequest, context: grpc.ServicerCont Returns: GetCostsResponse: Response containing filtered costs """ + total_cost = None + try: if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return cost_pb2.GetCostsResponse(costs=[]) + bulk = bulk_pb2.BulkResponse(total_process=0, total_failed=0) + result = [cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False)] + return cost_dto_pb2.ListCostsRequest(result=result, bulk=bulk) # Get costs for this mission mission_costs = self.costs.get(request.mission_id, []) + total_cost = len(mission_costs) # Apply filters filtered_costs = mission_costs @@ -233,28 +175,23 @@ def GetCosts(self, request: cost_pb2.GetCostsRequest, context: grpc.ServicerCont filtered_costs = [c for c in filtered_costs if c["name"] in request.filter.names] # Filter by cost types if provided - if request.filter and request.filter.cost_types: - # Convert protobuf enum integer values to Python CostType enums - # Protobuf enum: 1 = TOKEN_INPUT -> Python: CostType.TOKEN_INPUT - proto_to_python_cost_type = { - cost_pb2.TOKEN_INPUT: CostType.TOKEN_INPUT, - cost_pb2.TOKEN_OUTPUT: CostType.TOKEN_OUTPUT, - cost_pb2.API_CALL: CostType.API_CALL, - cost_pb2.STORAGE: CostType.STORAGE, - cost_pb2.TIME: CostType.TIME, - cost_pb2.OTHER: CostType.OTHER, - } - filter_types = [proto_to_python_cost_type.get(ct, CostType.OTHER) for ct in request.filter.cost_types] - filtered_costs = [c for c in filtered_costs if c["cost_type"] in filter_types] + if request.filter and request.filter.types: + filter_types = [CostType.from_proto(ct) for ct in request.filter.types] + filtered_costs = [c for c in filtered_costs if c["type"] in filter_types] # Convert to proto messages cost_protos = [self._cost_dict_to_proto(cost) for cost in filtered_costs] + items_results = [cost_messages_pb2.CostResult(cost=cost) for cost in cost_protos] logger.info(f"Retrieved {len(filtered_costs)} filtered costs for mission {request.mission_id}") - return cost_pb2.GetCostsResponse(costs=cost_protos) + pagination = pagination_pb2.PaginationResponse(total_count=len(items_results)) + bulk = bulk_pb2.BulkResponse(total_process=len(items_results), total_failed=0, pagination=pagination) + return cost_dto_pb2.ListCostsResponse(bulk=bulk, result=items_results) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in GetCosts: {e}", exc_info=True) - return cost_pb2.GetCostsResponse(costs=[]) + items_results = [cost_messages_pb2.CostResult(error=bulk_pb2.OperationError(code=grpc.StatusCode.INTERNAL, message="Error in GetCosts"))] + bulk = bulk_pb2.BulkResponse(total_process=total_cost, total_failed=total_cost) + return cost_dto_pb2.ListCostsResponse(bulk=bulk, result=items_results) diff --git a/tests/services/cost/test_cost_limits.py b/tests/services/cost/test_cost_limits.py index 659de98b..2d89d970 100644 --- a/tests/services/cost/test_cost_limits.py +++ b/tests/services/cost/test_cost_limits.py @@ -15,9 +15,8 @@ import pytest -from digitalkin.models.services.cost import AmountLimit, CostTypeEnum, QuantityLimit -from digitalkin.services.cost.cost_strategy import CostConfig -from digitalkin.services.cost.default_cost import DefaultCost +from digitalkin.models.services.cost import AmountLimit, CostType, QuantityLimit, CostConfig +from digitalkin.services import DefaultCost # Set timeout for all tests in this file pytestmark = pytest.mark.timeout(10) @@ -33,36 +32,36 @@ def sample_config() -> dict[str, CostConfig]: """Create sample cost configuration.""" return { "gpt4_input": CostConfig( - cost_name="gpt4_input", - cost_type="TOKEN_INPUT", + name="gpt4_input", + type=CostType.TOKEN_INPUT, description="GPT-4 input tokens", unit="tokens", rate=0.00003, # $0.03 per 1k tokens ), "gpt4_output": CostConfig( - cost_name="gpt4_output", - cost_type="TOKEN_OUTPUT", + name="gpt4_output", + type=CostType.TOKEN_OUTPUT, description="GPT-4 output tokens", unit="tokens", rate=0.00006, # $0.06 per 1k tokens ), "api_call": CostConfig( - cost_name="api_call", - cost_type="API_CALL", + name="api_call", + type=CostType.API_CALL, description="API call", unit="calls", rate=0.001, # $0.001 per call ), "storage": CostConfig( - cost_name="storage", - cost_type="STORAGE", + name="storage", + type=CostType.STORAGE, description="Storage", unit="GB", rate=0.02, # $0.02 per GB ), "compute_time": CostConfig( - cost_name="compute_time", - cost_type="TIME", + name="compute_time", + type=CostType.TIME, description="Compute time", unit="hours", rate=0.05, # $0.05 per hour @@ -93,12 +92,12 @@ def test_quantity_limit_creation(self) -> None: """Test QuantityLimit creation.""" limit = QuantityLimit( name="gpt4_input", - type=CostTypeEnum.TOKEN_INPUT, + type=CostType.TOKEN_INPUT, max_value=10000.0, ) assert limit.name == "gpt4_input" - assert limit.type == CostTypeEnum.TOKEN_INPUT + assert limit.type == CostType.TOKEN_INPUT assert limit.max_value == 10000.0 assert limit.limit_type == "quantity" @@ -106,12 +105,12 @@ def test_amount_limit_creation(self) -> None: """Test AmountLimit creation.""" limit = AmountLimit( name="api_call", - type=CostTypeEnum.API_CALL, + type=CostType.API_CALL, max_value=1.0, ) assert limit.name == "api_call" - assert limit.type == CostTypeEnum.API_CALL + assert limit.type == CostType.API_CALL assert limit.max_value == 1.0 assert limit.limit_type == "amount" @@ -119,14 +118,14 @@ def test_quantity_limit_serialization(self) -> None: """Test QuantityLimit serializes correctly.""" limit = QuantityLimit( name="storage", - type=CostTypeEnum.STORAGE, + type=CostType.STORAGE, max_value=100.0, ) data = limit.model_dump() assert data["name"] == "storage" - assert data["type"] == CostTypeEnum.STORAGE + assert data["type"] == CostType.STORAGE assert data["max_value"] == 100.0 assert data["limit_type"] == "quantity" @@ -134,14 +133,14 @@ def test_amount_limit_serialization(self) -> None: """Test AmountLimit serializes correctly.""" limit = AmountLimit( name="gpt4_output", - type=CostTypeEnum.TOKEN_OUTPUT, + type=CostType.TOKEN_OUTPUT, max_value=5.0, ) data = limit.model_dump() assert data["name"] == "gpt4_output" - assert data["type"] == CostTypeEnum.TOKEN_OUTPUT + assert data["type"] == CostType.TOKEN_OUTPUT assert data["max_value"] == 5.0 assert data["limit_type"] == "amount" @@ -157,7 +156,7 @@ class TestSetLimits: async def test_set_single_quantity_limit(self, cost_service: DefaultCost) -> None: """Test setting a single quantity limit.""" limits = [ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ] await cost_service.set_limits(limits) @@ -168,7 +167,7 @@ async def test_set_single_quantity_limit(self, cost_service: DefaultCost) -> Non async def test_set_single_amount_limit(self, cost_service: DefaultCost) -> None: """Test setting a single amount limit.""" limits = [ - AmountLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=1.0), + AmountLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=1.0), ] await cost_service.set_limits(limits) @@ -179,9 +178,9 @@ async def test_set_single_amount_limit(self, cost_service: DefaultCost) -> None: async def test_set_multiple_limits(self, cost_service: DefaultCost) -> None: """Test setting multiple limits of different types.""" limits = [ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), - AmountLimit(name="gpt4_output", type=CostTypeEnum.TOKEN_OUTPUT, max_value=5.0), - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=1000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), + AmountLimit(name="gpt4_output", type=CostType.TOKEN_OUTPUT, max_value=5.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=1000.0), ] await cost_service.set_limits(limits) @@ -199,7 +198,7 @@ async def test_set_limits_resets_accumulated(self, cost_service: DefaultCost) -> # Set new limits limits = [ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ] await cost_service.set_limits(limits) @@ -211,14 +210,14 @@ async def test_set_limits_replaces_existing(self, cost_service: DefaultCost) -> """Test that set_limits replaces existing limits.""" # Set initial limits await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ]) assert cost_service._limits["gpt4_input"].max_value == 10000.0 # Replace with new limits await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=20000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=20000.0), ]) assert cost_service._limits["gpt4_input"].max_value == 20000.0 @@ -239,7 +238,7 @@ async def test_check_limit_no_limit_set(self, cost_service: DefaultCost) -> None async def test_check_limit_quantity_under_limit(self, cost_service: DefaultCost) -> None: """Test check_limit returns True when quantity is under limit.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ]) assert await cost_service.check_limit("gpt4_input", 5000.0) is True @@ -247,7 +246,7 @@ async def test_check_limit_quantity_under_limit(self, cost_service: DefaultCost) async def test_check_limit_quantity_exceeds_limit(self, cost_service: DefaultCost) -> None: """Test check_limit returns False when quantity exceeds limit.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ]) assert await cost_service.check_limit("gpt4_input", 15000.0) is False @@ -255,7 +254,7 @@ async def test_check_limit_quantity_exceeds_limit(self, cost_service: DefaultCos async def test_check_limit_amount_under_limit(self, cost_service: DefaultCost) -> None: """Test check_limit returns True when projected amount is under limit.""" await cost_service.set_limits([ - AmountLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=1.0), + AmountLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=1.0), ]) # 10000 tokens * $0.00003/token = $0.30 @@ -264,7 +263,7 @@ async def test_check_limit_amount_under_limit(self, cost_service: DefaultCost) - async def test_check_limit_amount_exceeds_limit(self, cost_service: DefaultCost) -> None: """Test check_limit returns False when projected amount exceeds limit.""" await cost_service.set_limits([ - AmountLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=0.10), + AmountLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=0.10), ]) # 10000 tokens * $0.00003/token = $0.30, which exceeds $0.10 limit @@ -273,7 +272,7 @@ async def test_check_limit_amount_exceeds_limit(self, cost_service: DefaultCost) async def test_check_limit_with_accumulated_quantity(self, cost_service: DefaultCost) -> None: """Test check_limit considers accumulated quantity.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ]) # Simulate previous usage @@ -285,7 +284,7 @@ async def test_check_limit_with_accumulated_quantity(self, cost_service: Default async def test_check_limit_with_accumulated_amount(self, cost_service: DefaultCost) -> None: """Test check_limit considers accumulated amount.""" await cost_service.set_limits([ - AmountLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=0.50), + AmountLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=0.50), ]) # Simulate previous usage: $0.30 already spent @@ -300,7 +299,7 @@ async def test_check_limit_with_accumulated_amount(self, cost_service: DefaultCo async def test_check_limit_exact_boundary(self, cost_service: DefaultCost) -> None: """Test check_limit at exact boundary (equal to limit).""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ]) # Exactly at limit should pass @@ -309,7 +308,7 @@ async def test_check_limit_exact_boundary(self, cost_service: DefaultCost) -> No async def test_check_limit_config_not_found(self, cost_service: DefaultCost) -> None: """Test check_limit returns True when config doesn't exist.""" await cost_service.set_limits([ - QuantityLimit(name="nonexistent", type=CostTypeEnum.CUSTOM, max_value=100.0), + QuantityLimit(name="nonexistent", type=CostType.CUSTOM, max_value=100.0), ]) # Returns True - config doesn't exist, can't calculate @@ -327,17 +326,17 @@ class TestAccumulatedTracking: async def test_accumulated_quantity_tracking(self, cost_service: DefaultCost) -> None: """Test that quantity is tracked correctly via manual accumulation.""" await cost_service.set_limits([ - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) # Simulate tracking by adding costs and manually updating accumulated - await cost_service.add("call_1", "api_call", 25.0) + await cost_service.create("call_1", "api_call", 25.0) cost_service._accumulated["api_call_quantity"] = 25.0 - await cost_service.add("call_2", "api_call", 30.0) + await cost_service.create("call_2", "api_call", 30.0) cost_service._accumulated["api_call_quantity"] = 55.0 - await cost_service.add("call_3", "api_call", 20.0) + await cost_service.create("call_3", "api_call", 20.0) cost_service._accumulated["api_call_quantity"] = 75.0 assert cost_service._accumulated["api_call_quantity"] == 75.0 @@ -345,7 +344,7 @@ async def test_accumulated_quantity_tracking(self, cost_service: DefaultCost) -> async def test_accumulated_affects_check_limit(self, cost_service: DefaultCost) -> None: """Test that accumulated values affect check_limit results.""" await cost_service.set_limits([ - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) # Initially should allow 50 @@ -363,14 +362,14 @@ async def test_accumulated_affects_check_limit(self, cost_service: DefaultCost) async def test_accumulated_reset_on_new_limits(self, cost_service: DefaultCost) -> None: """Test that accumulated values reset when limits are re-set.""" await cost_service.set_limits([ - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) cost_service._accumulated["api_call_quantity"] = 50.0 # Re-set limits (simulating new session) await cost_service.set_limits([ - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) # Accumulated should be reset @@ -388,7 +387,7 @@ class TestLimitEdgeCases: async def test_zero_quantity(self, cost_service: DefaultCost) -> None: """Test handling of zero quantity.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), ]) # Zero quantity should pass check @@ -397,7 +396,7 @@ async def test_zero_quantity(self, cost_service: DefaultCost) -> None: async def test_very_small_quantities(self, cost_service: DefaultCost) -> None: """Test handling of very small quantities.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=1.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=1.0), ]) # Accumulate many small quantities @@ -418,7 +417,7 @@ async def test_very_small_quantities(self, cost_service: DefaultCost) -> None: async def test_floating_point_precision(self, cost_service: DefaultCost) -> None: """Test floating point precision in limit calculations.""" await cost_service.set_limits([ - AmountLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=0.001), + AmountLimit(name="api_call", type=CostType.API_CALL, max_value=0.001), ]) # rate = 0.001 per call, limit = 0.001 @@ -434,7 +433,7 @@ async def test_floating_point_precision(self, cost_service: DefaultCost) -> None async def test_very_large_limit(self, cost_service: DefaultCost) -> None: """Test handling of very large limits.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=1e12), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=1e12), ]) # Large but valid usage should pass @@ -444,8 +443,8 @@ async def test_limit_with_zero_rate_config(self, sample_config: dict[str, CostCo """Test limit checking with zero rate config.""" # Add a zero-rate config sample_config["free_tier"] = CostConfig( - cost_name="free_tier", - cost_type="OTHER", + name="free_tier", + type=CostType.OTHER, description="Free tier", unit="requests", rate=0.0, @@ -459,7 +458,7 @@ async def test_limit_with_zero_rate_config(self, sample_config: dict[str, CostCo ) await cost_service.set_limits([ - QuantityLimit(name="free_tier", type=CostTypeEnum.CUSTOM, max_value=100.0), + QuantityLimit(name="free_tier", type=CostType.CUSTOM, max_value=100.0), ]) # Should work with zero rate - quantity check still applies @@ -478,8 +477,8 @@ class TestIndependentLimits: async def test_independent_quantity_limits(self, cost_service: DefaultCost) -> None: """Test that quantity limits for different configs are independent.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), - QuantityLimit(name="gpt4_output", type=CostTypeEnum.TOKEN_OUTPUT, max_value=5000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), + QuantityLimit(name="gpt4_output", type=CostType.TOKEN_OUTPUT, max_value=5000.0), ]) # Use up gpt4_input limit via accumulated @@ -494,8 +493,8 @@ async def test_independent_quantity_limits(self, cost_service: DefaultCost) -> N async def test_mixed_limit_types(self, cost_service: DefaultCost) -> None: """Test mixing quantity and amount limits on different configs.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=10000.0), - AmountLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=0.50), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=10000.0), + AmountLimit(name="api_call", type=CostType.API_CALL, max_value=0.50), ]) # gpt4_input uses quantity tracking @@ -519,7 +518,7 @@ class TestConcurrentUsageSimulation: async def test_burst_usage_pattern(self, cost_service: DefaultCost) -> None: """Test burst usage pattern checking against limits.""" await cost_service.set_limits([ - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) # Simulate checking before 50 calls @@ -541,9 +540,9 @@ async def test_burst_usage_pattern(self, cost_service: DefaultCost) -> None: async def test_mixed_config_burst(self, cost_service: DefaultCost) -> None: """Test burst usage across multiple configs.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=50000.0), - QuantityLimit(name="gpt4_output", type=CostTypeEnum.TOKEN_OUTPUT, max_value=25000.0), - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=50000.0), + QuantityLimit(name="gpt4_output", type=CostType.TOKEN_OUTPUT, max_value=25000.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) input_total = 0.0 diff --git a/tests/services/cost/test_cost_stress.py b/tests/services/cost/test_cost_stress.py index 5ad2602f..5deff040 100644 --- a/tests/services/cost/test_cost_stress.py +++ b/tests/services/cost/test_cost_stress.py @@ -22,14 +22,15 @@ import grpc_testing import pytest from agentic_mesh_protocol.cost.v1 import cost_service_pb2, cost_service_pb2_grpc + +from digitalkin.exception.cost import CostServiceError +from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.services.cost import AmountLimit, CostType, QuantityLimit, CostConfig +from digitalkin.services import DefaultCost +from digitalkin.services.cost import GrpcCost from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext from tests.services.cost.mock_cost_servicer import MockCostServicer -from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode -from digitalkin.models.services.cost import AmountLimit, CostTypeEnum, QuantityLimit -from digitalkin.services.cost.cost_strategy import CostConfig, CostServiceError -from digitalkin.services.cost.default_cost import DefaultCost -from digitalkin.services.cost.grpc_cost import GrpcCost from tests.fixtures.stress_reporter import StressReporter # Set timeout for stress tests @@ -46,29 +47,29 @@ def sample_config() -> dict[str, CostConfig]: """Create sample cost configuration.""" return { "gpt4_input": CostConfig( - cost_name="gpt4_input", - cost_type="TOKEN_INPUT", + name="gpt4_input", + type=CostType.TOKEN_INPUT, description="GPT-4 input tokens", unit="tokens", rate=0.00003, ), "gpt4_output": CostConfig( - cost_name="gpt4_output", - cost_type="TOKEN_OUTPUT", + name="gpt4_output", + type=CostType.TOKEN_OUTPUT, description="GPT-4 output tokens", unit="tokens", rate=0.00006, ), "api_call": CostConfig( - cost_name="api_call", - cost_type="API_CALL", + name="api_call", + type=CostType.API_CALL, description="API call", unit="calls", rate=0.001, ), "storage": CostConfig( - cost_name="storage", - cost_type="STORAGE", + name="storage", + type=CostType.STORAGE, description="Storage", unit="GB", rate=0.02, @@ -146,12 +147,12 @@ async def test_add_thousand_costs(self, cost_service: DefaultCost) -> None: """Test adding 1000 costs sequentially.""" t0 = time.perf_counter() for i in range(1000): - await cost_service.add(f"cost_{i}", "gpt4_input", 100.0) + await cost_service.create(f"cost_{i}", "gpt4_input", 100.0) elapsed = time.perf_counter() - t0 - # Verify all costs were stored - use get_filtered with names + # Verify all costs were stored - use list with names names = [f"cost_{i}" for i in range(1000)] - costs = await cost_service.get_filtered(names=names) + costs = await cost_service.list(names=names) rpt = StressReporter("High Volume: 1,000 Sequential Adds") rpt.metric("Total costs", StressReporter.count(len(costs))) @@ -168,7 +169,7 @@ async def test_add_costs_different_configs(self, cost_service: DefaultCost) -> N t0 = time.perf_counter() for config_name in configs: for i in range(250): - await cost_service.add(f"{config_name}_{i}", config_name, 10.0) + await cost_service.create(f"{config_name}_{i}", config_name, 10.0) elapsed = time.perf_counter() - t0 # Verify counts per type - use names filter @@ -177,10 +178,10 @@ async def test_add_costs_different_configs(self, cost_service: DefaultCost) -> N api_names = [f"api_call_{i}" for i in range(250)] storage_names = [f"storage_{i}" for i in range(250)] - input_costs = await cost_service.get_filtered(names=input_names) - output_costs = await cost_service.get_filtered(names=output_names) - api_costs = await cost_service.get_filtered(names=api_names) - storage_costs = await cost_service.get_filtered(names=storage_names) + input_costs = await cost_service.list(names=input_names) + output_costs = await cost_service.list(names=output_names) + api_costs = await cost_service.list(names=api_names) + storage_costs = await cost_service.list(names=storage_names) rpt = StressReporter("High Volume: 4 Config Types x 250 Each") rpt.metric("Duration", StressReporter.duration(elapsed)) @@ -202,7 +203,7 @@ async def test_cost_calculation_accuracy_at_scale(self, cost_service: DefaultCos for i in range(500): quantity = i * 10.0 # Increasing quantities - await cost_service.add(f"scaled_cost_{i}", "gpt4_input", quantity) + await cost_service.create(f"scaled_cost_{i}", "gpt4_input", quantity) total_quantity += quantity # Calculate expected total cost @@ -210,7 +211,7 @@ async def test_cost_calculation_accuracy_at_scale(self, cost_service: DefaultCos # Sum actual costs - use names filter names = [f"scaled_cost_{i}" for i in range(500)] - costs = await cost_service.get_filtered(names=names) + costs = await cost_service.list(names=names) actual_total = sum(c.cost for c in costs) delta = abs(actual_total - expected_total) @@ -251,14 +252,14 @@ async def test_multiple_missions_isolation(self, sample_config: dict[str, CostCo # Add costs to each mission for i, service in enumerate(services): for j in range(100): - await service.add(f"cost_{i}_{j}", "gpt4_input", float(i * 100 + j)) + await service.create(f"cost_{i}_{j}", "gpt4_input", float(i * 100 + j)) elapsed = time.perf_counter() - t0 # Verify isolation using names filter all_isolated = True for i, service in enumerate(services): names = [f"cost_{i}_{j}" for j in range(100)] - costs = await service.get_filtered(names=names) + costs = await service.list(names=names) expected_quantities = {float(i * 100 + j) for j in range(100)} actual_quantities = {c.quantity for c in costs} if len(costs) != 100 or actual_quantities != expected_quantities: @@ -293,12 +294,12 @@ async def test_mission_isolation_with_same_cost_names( ) # Add cost with same name to both missions - await service1.add("shared_name_cost", "gpt4_input", 1000.0) - await service2.add("shared_name_cost", "gpt4_input", 2000.0) + await service1.create("shared_name_cost", "gpt4_input", 1000.0) + await service2.create("shared_name_cost", "gpt4_input", 2000.0) # Each mission should have its own cost - costs1 = await service1.get("shared_name_cost") - costs2 = await service2.get("shared_name_cost") + costs1 = await service1.list(["shared_name_cost"]) + costs2 = await service2.list(["shared_name_cost"]) isolated = ( len(costs1) == 1 @@ -331,9 +332,9 @@ async def test_very_large_quantity(self, cost_service: DefaultCost) -> None: """Test handling of very large quantities (billions).""" large_quantity = 1_000_000_000_000.0 # 1 trillion - await cost_service.add("huge_usage", "gpt4_input", large_quantity) + await cost_service.create("huge_usage", "gpt4_input", large_quantity) - costs = await cost_service.get("huge_usage") + costs = await cost_service.list("huge_usage") expected_cost = large_quantity * 0.00003 delta = abs(costs[0].cost - expected_cost) @@ -352,9 +353,9 @@ async def test_very_small_quantity(self, cost_service: DefaultCost) -> None: """Test handling of very small quantities.""" small_quantity = 0.000001 - await cost_service.add("tiny_usage", "gpt4_input", small_quantity) + await cost_service.create("tiny_usage", "gpt4_input", small_quantity) - costs = await cost_service.get("tiny_usage") + costs = await cost_service.list("tiny_usage") rpt = StressReporter("Small Quantity: 0.000001") rpt.metric("Quantity stored", f"{costs[0].quantity:.6f}") @@ -369,7 +370,7 @@ async def test_quantity_extremes_with_limits(self, cost_service: DefaultCost) -> await cost_service.set_limits([ QuantityLimit( name="gpt4_input", - type=CostTypeEnum.TOKEN_INPUT, + type=CostType.TOKEN_INPUT, max_value=1e15, # 1 quadrillion ), ]) @@ -412,7 +413,7 @@ async def test_large_number_of_costs_memory(self, cost_service: DefaultCost) -> # Add many costs for i in range(10000): - await cost_service.add(f"memory_test_{i}", "gpt4_input", float(i)) + await cost_service.create(f"memory_test_{i}", "gpt4_input", float(i)) final_size = sys.getsizeof(cost_service.db) ratio = final_size / baseline_size if baseline_size > 0 else float("inf") @@ -431,13 +432,13 @@ async def test_filter_performance_with_many_costs(self, cost_service: DefaultCos # Add many costs of different types for i in range(5000): config = ["gpt4_input", "gpt4_output", "api_call", "storage"][i % 4] - await cost_service.add(f"perf_test_{i}", config, float(i)) + await cost_service.create(f"perf_test_{i}", config, float(i)) # Time the filter operation - use names filter for gpt4_input costs gpt4_input_names = [f"perf_test_{i}" for i in range(0, 5000, 4)] # Every 4th starting at 0 t0 = time.perf_counter() - results = await cost_service.get_filtered(names=gpt4_input_names) + results = await cost_service.list(names=gpt4_input_names) elapsed = time.perf_counter() - t0 rpt = StressReporter("Filter Performance: 5,000 Costs") @@ -461,7 +462,7 @@ class TestGrpcStress: @pytest.mark.grpc @pytest.mark.stress - async def test_rapid_sequential_adds( + async def test_rapid_sequential_create( self, grpc_client: GrpcCost, test_channel: grpc_testing.Channel, @@ -470,18 +471,18 @@ async def test_rapid_sequential_adds( ) -> None: """Test rapid sequential cost additions.""" service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] t0 = time.perf_counter() for i in range(50): name = f"rapid_{i}_{secrets.token_hex(4)}" - future = thread_pool.submit(asyncio.run, grpc_client.add(name, "gpt4_input", 100.0)) + future = thread_pool.submit(asyncio.run, grpc_client.create(name, "gpt4_input", 100.0)) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -512,38 +513,38 @@ async def test_mixed_operations_under_load( service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] # First, add a batch of costs - add_method = service_desc.methods_by_name["AddCost"] + add_method = service_desc.methods_by_name["CreateCost"] t0 = time.perf_counter() for i in range(20): name = f"mixed_{i}" - future = thread_pool.submit(asyncio.run, grpc_client.add(name, "gpt4_input", 100.0)) + future = thread_pool.submit(asyncio.run, grpc_client.create(name, "gpt4_input", 100.0)) _, request, rpc = test_channel.take_unary_unary(add_method) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") future.result(timeout=5.0) # Now do mixed operations - get_method = service_desc.methods_by_name["GetCost"] + get_method = service_desc.methods_by_name["ListCosts"] for i in range(10): # Add name = f"mixed_extra_{i}" - future_add = thread_pool.submit(asyncio.run, grpc_client.add(name, "gpt4_output", 50.0)) + future_add = thread_pool.submit(asyncio.run, grpc_client.create(name, "gpt4_output", 50.0)) _, request, rpc = test_channel.take_unary_unary(add_method) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") - future_add.result(timeout=5.0) + result = future_add.result(timeout=5.0) # Get - future_get = thread_pool.submit(asyncio.run, grpc_client.get(f"mixed_{i}")) + future_get = thread_pool.submit(asyncio.run, grpc_client.list(f"mixed_{i}")) _, request, rpc = test_channel.take_unary_unary(get_method) context = FakeContext() - response = mock_servicer.GetCost(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") future_get.result(timeout=5.0) @@ -573,7 +574,7 @@ class TestLimitEnforcementUnderStress: async def test_limit_enforcement_high_frequency(self, cost_service: DefaultCost) -> None: """Test that limits are correctly enforced under high-frequency checks.""" await cost_service.set_limits([ - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=1000.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=1000.0), ]) t0 = time.perf_counter() @@ -581,7 +582,7 @@ async def test_limit_enforcement_high_frequency(self, cost_service: DefaultCost) total = 0.0 for i in range(1000): assert await cost_service.check_limit("api_call", 1.0) is True - await cost_service.add(f"freq_{i}", "api_call", 1.0) + await cost_service.create(f"freq_{i}", "api_call", 1.0) total += 1.0 cost_service._accumulated["api_call_quantity"] = total elapsed = time.perf_counter() - t0 @@ -589,7 +590,7 @@ async def test_limit_enforcement_high_frequency(self, cost_service: DefaultCost) exceeded = await cost_service.check_limit("api_call", 1.0) is False names = [f"freq_{i}" for i in range(1000)] - costs = await cost_service.get_filtered(names=names) + costs = await cost_service.list(names=names) rpt = StressReporter("Limit Enforcement: 1,000 High-Freq Checks") rpt.metric("Checks + adds", StressReporter.count(1000)) @@ -610,7 +611,7 @@ async def test_amount_limit_precision_under_stress(self, cost_service: DefaultCo """ # Set a limit with buffer for floating point precision await cost_service.set_limits([ - AmountLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=1.01), # Slight buffer + AmountLimit(name="api_call", type=CostType.API_CALL, max_value=1.01), # Slight buffer ]) t0 = time.perf_counter() @@ -618,7 +619,7 @@ async def test_amount_limit_precision_under_stress(self, cost_service: DefaultCo total_amount = 0.0 for i in range(1000): assert await cost_service.check_limit("api_call", 1.0) is True - await cost_service.add(f"precise_{i}", "api_call", 1.0) + await cost_service.create(f"precise_{i}", "api_call", 1.0) total_amount += 0.001 # rate * 1.0 cost_service._accumulated["api_call_amount"] = total_amount elapsed = time.perf_counter() - t0 @@ -637,9 +638,9 @@ async def test_amount_limit_precision_under_stress(self, cost_service: DefaultCo async def test_multiple_limits_stress(self, cost_service: DefaultCost) -> None: """Test multiple limits under stress conditions.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=50000.0), - QuantityLimit(name="gpt4_output", type=CostTypeEnum.TOKEN_OUTPUT, max_value=25000.0), - QuantityLimit(name="api_call", type=CostTypeEnum.API_CALL, max_value=100.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=50000.0), + QuantityLimit(name="gpt4_output", type=CostType.TOKEN_OUTPUT, max_value=25000.0), + QuantityLimit(name="api_call", type=CostType.API_CALL, max_value=100.0), ]) input_total = 0.0 @@ -649,17 +650,17 @@ async def test_multiple_limits_stress(self, cost_service: DefaultCost) -> None: t0 = time.perf_counter() for i in range(100): assert await cost_service.check_limit("gpt4_input", 500.0) is True - await cost_service.add(f"input_{i}", "gpt4_input", 500.0) + await cost_service.create(f"input_{i}", "gpt4_input", 500.0) input_total += 500.0 cost_service._accumulated["gpt4_input_quantity"] = input_total assert await cost_service.check_limit("gpt4_output", 250.0) is True - await cost_service.add(f"output_{i}", "gpt4_output", 250.0) + await cost_service.create(f"output_{i}", "gpt4_output", 250.0) output_total += 250.0 cost_service._accumulated["gpt4_output_quantity"] = output_total assert await cost_service.check_limit("api_call", 1.0) is True - await cost_service.add(f"call_{i}", "api_call", 1.0) + await cost_service.create(f"call_{i}", "api_call", 1.0) call_total += 1.0 cost_service._accumulated["api_call_quantity"] = call_total elapsed = time.perf_counter() - t0 @@ -692,8 +693,8 @@ class TestErrorRecovery: async def test_continue_after_limit_check_fails(self, cost_service: DefaultCost) -> None: """Test that service continues to work after limit check returns False.""" await cost_service.set_limits([ - QuantityLimit(name="gpt4_input", type=CostTypeEnum.TOKEN_INPUT, max_value=1000.0), - QuantityLimit(name="gpt4_output", type=CostTypeEnum.TOKEN_OUTPUT, max_value=1000.0), + QuantityLimit(name="gpt4_input", type=CostType.TOKEN_INPUT, max_value=1000.0), + QuantityLimit(name="gpt4_output", type=CostType.TOKEN_OUTPUT, max_value=1000.0), ]) # Use up gpt4_input limit @@ -703,14 +704,14 @@ async def test_continue_after_limit_check_fails(self, cost_service: DefaultCost) # But gpt4_output should still work output_ok_1 = await cost_service.check_limit("gpt4_output", 500.0) is True - await cost_service.add("output_1", "gpt4_output", 500.0) + await cost_service.create("output_1", "gpt4_output", 500.0) cost_service._accumulated["gpt4_output_quantity"] = 500.0 output_ok_2 = await cost_service.check_limit("gpt4_output", 500.0) is True - await cost_service.add("output_2", "gpt4_output", 500.0) + await cost_service.create("output_2", "gpt4_output", 500.0) cost_service._accumulated["gpt4_output_quantity"] = 1000.0 - output_costs = await cost_service.get_filtered(names=["output_1", "output_2"]) + output_costs = await cost_service.list(names=["output_1", "output_2"]) passed = input_blocked and output_ok_1 and output_ok_2 and len(output_costs) == 2 rpt = StressReporter("Error Recovery: Continue After Limit Fail") @@ -725,17 +726,17 @@ async def test_invalid_config_doesnt_corrupt_state(self, cost_service: DefaultCo """Test that invalid config errors don't corrupt service state.""" # Add some valid costs for i in range(10): - await cost_service.add(f"valid_{i}", "gpt4_input", 100.0) + await cost_service.create(f"valid_{i}", "gpt4_input", 100.0) # Try invalid config — pytest.raises ensures exception is raised with pytest.raises(CostServiceError): - await cost_service.add("invalid", "nonexistent_config", 100.0) + await cost_service.create("invalid", "nonexistent_config", 100.0) # Service should still work - await cost_service.add("after_error", "gpt4_input", 100.0) + await cost_service.create("after_error", "gpt4_input", 100.0) names = [f"valid_{i}" for i in range(10)] + ["after_error"] - costs = await cost_service.get_filtered(names=names) + costs = await cost_service.list(names=names) rpt = StressReporter("Error Recovery: Invalid Config") rpt.metric("Pre-error costs", StressReporter.count(10)) @@ -756,13 +757,13 @@ class TestDataIntegrity: async def test_cost_data_immutability(self, cost_service: DefaultCost) -> None: """Test that retrieved cost data can't corrupt internal state.""" - await cost_service.add("original", "gpt4_input", 1000.0) + await cost_service.create("original", "gpt4_input", 1000.0) - costs = await cost_service.get("original") + costs = await cost_service.list("original") retrieved_quantity = costs[0].quantity # Re-fetch and verify - costs_again = await cost_service.get("original") + costs_again = await cost_service.list("original") immutable = costs_again[0].quantity == retrieved_quantity rpt = StressReporter("Data Integrity: Immutability") @@ -776,14 +777,14 @@ async def test_cost_data_immutability(self, cost_service: DefaultCost) -> None: async def test_concurrent_reads_consistency(self, cost_service: DefaultCost) -> None: """Test that concurrent reads return consistent data.""" for i in range(100): - await cost_service.add(f"concurrent_{i}", "gpt4_input", float(i)) + await cost_service.create(f"concurrent_{i}", "gpt4_input", float(i)) names = [f"concurrent_{i}" for i in range(100)] t0 = time.perf_counter() results = [] for _ in range(10): - results.append(await cost_service.get_filtered(names=names)) + results.append(await cost_service.list(names=names)) elapsed = time.perf_counter() - t0 first_len = len(results[0]) diff --git a/tests/services/cost/test_grpc_cost.py b/tests/services/cost/test_grpc_cost.py index f1c7bffc..55adc03a 100644 --- a/tests/services/cost/test_grpc_cost.py +++ b/tests/services/cost/test_grpc_cost.py @@ -13,13 +13,14 @@ import grpc_testing import pytest from agentic_mesh_protocol.cost.v1 import cost_service_pb2, cost_service_pb2_grpc -from mock_cost_servicer import MockCostServicer -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext +from digitalkin.exception.cost import CostServiceError from digitalkin.grpc_servers.utils.exceptions import ServerError from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode -from digitalkin.services.cost.cost_strategy import CostConfig, CostData, CostServiceError, CostType -from digitalkin.services.cost.grpc_cost import GrpcCost +from digitalkin.models.services.cost import CostConfig, CostType +from digitalkin.services.cost.cost_grpc import GrpcCost +from mock_cost_servicer import MockCostServicer +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext service_instance = MockCostServicer() service_name = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] @@ -78,43 +79,43 @@ def cost_config() -> dict[str, CostConfig]: """ return { "gpt4_input": CostConfig( - cost_name="gpt4_input", - cost_type="TOKEN_INPUT", + name="gpt4_input", + type=CostType.TOKEN_INPUT, description="GPT-4 input tokens", unit="tokens", rate=0.00003, # $0.03 per 1k tokens ), "gpt4_output": CostConfig( - cost_name="gpt4_output", - cost_type="TOKEN_OUTPUT", + name="gpt4_output", + type=CostType.TOKEN_OUTPUT, description="GPT-4 output tokens", unit="tokens", rate=0.00006, # $0.06 per 1k tokens ), "api_call": CostConfig( - cost_name="api_call", - cost_type="API_CALL", + name="api_call", + type=CostType.API_CALL, description="API call", unit="calls", rate=0.001, # $0.001 per call ), "storage": CostConfig( - cost_name="storage", - cost_type="STORAGE", + name="storage", + type=CostType.STORAGE, description="Storage", unit="GB", rate=0.02, # $0.02 per GB ), "compute_time": CostConfig( - cost_name="compute_time", - cost_type="TIME", + name="compute_time", + type=CostType.TIME, description="Compute time", unit="hours", rate=0.05, # $0.05 per hour ), "other_cost": CostConfig( - cost_name="other_cost", - cost_type="OTHER", + name="other_cost", + type=CostType.OTHER, description="Other costs", unit="units", rate=0.01, @@ -150,11 +151,11 @@ def client(test_channel: grpc_testing.Channel, cost_config: dict[str, CostConfig # ============================================================================ -# Test: add() Method +# Test: Create() Method # ============================================================================ -class TestAddCost: +class TestCreateCost: """Tests for the add() method of GrpcCost service. Covers success cases, validation errors, various cost types, @@ -164,7 +165,7 @@ class TestAddCost: @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_add_cost_success( + def test_create_cost_success( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -183,18 +184,18 @@ def test_add_cost_success( quantity = 1000.0 # Start the client call in a separate thread - future = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", quantity)) + future = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", quantity)) # Get the method descriptor service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] # Intercept the pending unary-unary call _invocation_metadata, request, rpc = test_channel.take_unary_unary(method_desc) # Process with mock servicer context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) # Send response back to client rpc.send_initial_metadata(()) @@ -215,7 +216,7 @@ def test_add_cost_success( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - async def test_add_cost_invalid_config_name( + async def test_create_cost_invalid_config_name( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -232,7 +233,7 @@ async def test_add_cost_invalid_config_name( # Try to add cost with invalid config name with pytest.raises(CostServiceError, match="Cost config .* not found"): - await client.add(name, "nonexistent_config", quantity) + await client.create(name, "nonexistent_config", quantity) @pytest.mark.grpc @pytest.mark.integration @@ -264,15 +265,15 @@ def test_add_cost_various_types( name = f"test_{config_name}_{secrets.token_hex(4)}" # Start client call - future = thread_pool.submit(asyncio.run, client.add(name, config_name, quantity)) + future = thread_pool.submit(asyncio.run, client.create(name, config_name, quantity)) # Intercept and process service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -285,14 +286,14 @@ def test_add_cost_various_types( assert len(stored_costs) == len(configs) # Verify cost types - cost_types = [cost["cost_type"].name for cost in stored_costs] + cost_types = [cost["type"].name for cost in stored_costs] expected_types = [ct for _, ct, _ in configs] assert sorted(cost_types) == sorted(expected_types) @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_add_cost_calculation( + def test_create_cost_calculation( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -316,14 +317,14 @@ def test_add_cost_calculation( for config_name, quantity, expected_cost in test_cases: name = f"test_{config_name}_{secrets.token_hex(4)}" - future = thread_pool.submit(asyncio.run, client.add(name, config_name, quantity)) + future = thread_pool.submit(asyncio.run, client.create(name, config_name, quantity)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -338,7 +339,7 @@ def test_add_cost_calculation( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_add_cost_zero_quantity( + def test_create_cost_zero_quantity( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -354,14 +355,14 @@ def test_add_cost_zero_quantity( """ name = f"test_zero_{secrets.token_hex(4)}" - future = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", 0.0)) + future = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", 0.0)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) # Zero quantity should be rejected rpc.send_initial_metadata(()) @@ -374,7 +375,7 @@ def test_add_cost_zero_quantity( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_add_cost_negative_quantity( + def test_create_cost_negative_quantity( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -390,14 +391,14 @@ def test_add_cost_negative_quantity( """ name = f"test_negative_{secrets.token_hex(4)}" - future = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", -100.0)) + future = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", -100.0)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), context._code, context._details) @@ -425,14 +426,14 @@ def test_cost_with_special_characters_in_name( """ name = "test-cost_123.special@chars" - future = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", 100.0)) + future = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", 100.0)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -444,167 +445,12 @@ def test_cost_with_special_characters_in_name( stored_costs = mock_servicer.costs[client.mission_id] assert any(c["name"] == name for c in stored_costs) - -# ============================================================================ -# Test: get() Method -# ============================================================================ - - -class TestGetCost: - """Tests for the get() method of GrpcCost service. - - Covers retrieving costs by name, handling non-existent costs, - and retrieving multiple costs with the same name. - """ - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_get_cost_success( - self, - client: GrpcCost, - test_channel: grpc_testing.Channel, - mock_servicer: MockCostServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successful retrieval of costs by name. - - Args: - client: GrpcCost client for testing - test_channel: Mock gRPC channel - mock_servicer: Mock cost servicer - """ - # First, add a cost - name = f"test_get_{secrets.token_hex(4)}" - quantity = 1000.0 - - # Add cost - future_add = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", quantity)) - service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] - _, request, rpc = test_channel.take_unary_unary(method_desc) - context = FakeContext() - response = mock_servicer.AddCost(request, context) - rpc.send_initial_metadata(()) - rpc.terminate(response, (), grpc.StatusCode.OK, "") - future_add.result(timeout=5.0) - - # Now get the cost - future_get = thread_pool.submit(asyncio.run, client.get(name)) - - method_desc = service_desc.methods_by_name["GetCost"] - _, request, rpc = test_channel.take_unary_unary(method_desc) - - context = FakeContext() - response = mock_servicer.GetCost(request, context) - - rpc.send_initial_metadata(()) - rpc.terminate(response, (), grpc.StatusCode.OK, "") - - result = future_get.result(timeout=5.0) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], CostData) - assert result[0].name == name - assert result[0].quantity == quantity - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_get_cost_not_found( - self, - client: GrpcCost, - test_channel: grpc_testing.Channel, - mock_servicer: MockCostServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test getting a cost that doesn't exist. - - Args: - client: GrpcCost client for testing - test_channel: Mock gRPC channel - mock_servicer: Mock cost servicer - """ - name = f"nonexistent_{secrets.token_hex(4)}" - - future = thread_pool.submit(asyncio.run, client.get(name)) - - service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["GetCost"] - _, request, rpc = test_channel.take_unary_unary(method_desc) - - context = FakeContext() - response = mock_servicer.GetCost(request, context) - - rpc.send_initial_metadata(()) - rpc.terminate(response, (), grpc.StatusCode.OK, "") - - result = future.result(timeout=5.0) - assert isinstance(result, list) - assert len(result) == 0 - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_get_cost_multiple_with_same_name( - self, - client: GrpcCost, - test_channel: grpc_testing.Channel, - mock_servicer: MockCostServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test getting multiple costs with the same name. - - Args: - client: GrpcCost client for testing - test_channel: Mock gRPC channel - mock_servicer: Mock cost servicer - """ - name = f"test_multi_{secrets.token_hex(4)}" - - # Add multiple costs with the same name - quantities = [100.0, 200.0, 300.0] - service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - - for quantity in quantities: - future_add = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", quantity)) - method_desc = service_desc.methods_by_name["AddCost"] - _, request, rpc = test_channel.take_unary_unary(method_desc) - context = FakeContext() - response = mock_servicer.AddCost(request, context) - rpc.send_initial_metadata(()) - rpc.terminate(response, (), grpc.StatusCode.OK, "") - future_add.result(timeout=5.0) - - # Get all costs with this name - future_get = thread_pool.submit(asyncio.run, client.get(name)) - - method_desc = service_desc.methods_by_name["GetCost"] - _, request, rpc = test_channel.take_unary_unary(method_desc) - - context = FakeContext() - response = mock_servicer.GetCost(request, context) - - rpc.send_initial_metadata(()) - rpc.terminate(response, (), grpc.StatusCode.OK, "") - - result = future_get.result(timeout=5.0) - assert isinstance(result, list) - assert len(result) == 3 - assert all(isinstance(c, CostData) for c in result) - assert all(c.name == name for c in result) - - # Verify quantities - result_quantities = sorted([c.quantity for c in result]) - assert result_quantities == sorted(quantities) - - # ============================================================================ # Test: get_filtered() Method # ============================================================================ -class TestGetFilteredCost: +class TestListCost: """Tests for the get_filtered() method of GrpcCost service. Covers filtering by names, cost types, combinations of both, @@ -614,7 +460,7 @@ class TestGetFilteredCost: @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_get_filtered_by_names( + def test_get_costs_by_names( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -633,24 +479,24 @@ def test_get_filtered_by_names( service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] for name in names: - future_add = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", 100.0)) - method_desc = service_desc.methods_by_name["AddCost"] + future_add = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", 100.0)) + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") future_add.result(timeout=5.0) # Filter by subset of names filter_names = names[:3] - future_get = thread_pool.submit(asyncio.run, client.get_filtered(names=filter_names)) + future_get = thread_pool.submit(asyncio.run, client.list(names=filter_names)) - method_desc = service_desc.methods_by_name["GetCosts"] + method_desc = service_desc.methods_by_name["ListCosts"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.GetCosts(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -663,7 +509,7 @@ def test_get_filtered_by_names( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_get_filtered_by_cost_types( + def test_get_costs_by_types( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -679,32 +525,32 @@ def test_get_filtered_by_cost_types( """ # Add costs with different types configs = [ - ("gpt4_input", "TOKEN_INPUT"), - ("gpt4_output", "TOKEN_OUTPUT"), - ("api_call", "API_CALL"), - ("storage", "STORAGE"), + ("gpt4_input", CostType.TOKEN_INPUT), + ("gpt4_output", CostType.TOKEN_OUTPUT), + ("api_call", CostType.API_CALL), + ("storage", CostType.STORAGE), ] service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] for config_name, _ in configs: name = f"test_{config_name}_{secrets.token_hex(4)}" - future_add = thread_pool.submit(asyncio.run, client.add(name, config_name, 100.0)) - method_desc = service_desc.methods_by_name["AddCost"] + future_add = thread_pool.submit(asyncio.run, client.create(name, config_name, 100.0)) + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") future_add.result(timeout=5.0) # Filter by token types only - future_get = thread_pool.submit(asyncio.run, client.get_filtered(cost_types=["TOKEN_INPUT", "TOKEN_OUTPUT"])) + future_get = thread_pool.submit(asyncio.run, client.list(cost_types=[CostType.TOKEN_INPUT, CostType.TOKEN_OUTPUT])) - method_desc = service_desc.methods_by_name["GetCosts"] + method_desc = service_desc.methods_by_name["ListCosts"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.GetCosts(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -712,12 +558,12 @@ def test_get_filtered_by_cost_types( result = future_get.result(timeout=5.0) assert isinstance(result, list) assert len(result) == 2 - assert all(c.cost_type in {CostType.TOKEN_INPUT, CostType.TOKEN_OUTPUT} for c in result) + assert all(c.type in {CostType.TOKEN_INPUT, CostType.TOKEN_OUTPUT} for c in result) @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_get_filtered_by_names_and_types( + def test_get_costs_by_names_and_types( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -733,31 +579,31 @@ def test_get_filtered_by_names_and_types( """ # Add various costs test_data = [ - ("cost_a", "gpt4_input", "TOKEN_INPUT"), - ("cost_b", "gpt4_output", "TOKEN_OUTPUT"), - ("cost_c", "api_call", "API_CALL"), - ("cost_d", "gpt4_input", "TOKEN_INPUT"), + ("cost_a", "gpt4_input", CostType.TOKEN_INPUT), + ("cost_b", "gpt4_output", CostType.TOKEN_OUTPUT), + ("cost_c", "api_call", CostType.API_CALL), + ("cost_d", "gpt4_input", CostType.TOKEN_INPUT), ] service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] for name, config, _ in test_data: - future_add = thread_pool.submit(asyncio.run, client.add(name, config, 100.0)) - method_desc = service_desc.methods_by_name["AddCost"] + future_add = thread_pool.submit(asyncio.run, client.create(name, config, 100.0)) + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") future_add.result(timeout=5.0) # Filter by names and token input type - future_get = thread_pool.submit(asyncio.run, client.get_filtered(names=["cost_a", "cost_d"], cost_types=["TOKEN_INPUT"])) + future_get = thread_pool.submit(asyncio.run, client.list(names=["cost_a", "cost_d"], cost_types=[CostType.TOKEN_INPUT])) - method_desc = service_desc.methods_by_name["GetCosts"] + method_desc = service_desc.methods_by_name["ListCosts"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.GetCosts(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -766,12 +612,12 @@ def test_get_filtered_by_names_and_types( assert isinstance(result, list) assert len(result) == 2 assert all(c.name in {"cost_a", "cost_d"} for c in result) - assert all(c.cost_type == CostType.TOKEN_INPUT for c in result) + assert all(c.type == CostType.TOKEN_INPUT for c in result) @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_get_filtered_empty_results( + def test_get_costs_empty_results( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -786,14 +632,14 @@ def test_get_filtered_empty_results( mock_servicer: Mock cost servicer """ # Filter with non-existent names - future = thread_pool.submit(asyncio.run, client.get_filtered(names=["nonexistent"])) + future = thread_pool.submit(asyncio.run, client.list(names=["nonexistent"])) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["GetCosts"] + method_desc = service_desc.methods_by_name["ListCosts"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.GetCosts(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -805,7 +651,7 @@ def test_get_filtered_empty_results( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_get_filtered_no_filters( + def test_get_costs_no_filters( self, client: GrpcCost, test_channel: grpc_testing.Channel, @@ -824,23 +670,23 @@ def test_get_filtered_no_filters( for i in range(3): name = f"cost_{i}" - future_add = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", 100.0)) - method_desc = service_desc.methods_by_name["AddCost"] + future_add = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", 100.0)) + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") future_add.result(timeout=5.0) # Get all costs (no filter) - future_get = thread_pool.submit(asyncio.run, client.get_filtered()) + future_get = thread_pool.submit(asyncio.run, client.list()) - method_desc = service_desc.methods_by_name["GetCosts"] + method_desc = service_desc.methods_by_name["ListCosts"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.GetCosts(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -882,14 +728,14 @@ def test_cost_with_very_large_quantity( name = "large_quantity_test" quantity = 1_000_000_000.0 # 1 billion - future = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", quantity)) + future = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", quantity)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -922,14 +768,14 @@ def test_cost_with_fractional_quantity( name = "fractional_test" quantity = 123.456 - future = thread_pool.submit(asyncio.run, client.add(name, "gpt4_input", quantity)) + future = thread_pool.submit(asyncio.run, client.create(name, "gpt4_input", quantity)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -960,14 +806,14 @@ def test_multiple_missions_isolation( """ # Add costs for the test client's mission name1 = "mission1_cost" - future = thread_pool.submit(asyncio.run, client.add(name1, "gpt4_input", 100.0)) + future = thread_pool.submit(asyncio.run, client.create(name1, "gpt4_input", 100.0)) service_desc = cost_service_pb2.DESCRIPTOR.services_by_name["CostService"] - method_desc = service_desc.methods_by_name["AddCost"] + method_desc = service_desc.methods_by_name["CreateCost"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.AddCost(request, context) + response = mock_servicer.CreateCost(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -978,7 +824,7 @@ def test_multiple_missions_isolation( "cost": 50.0, "name": "mission2_cost", "unit": "tokens", - "cost_type": CostType.TOKEN_INPUT, + "type": CostType.TOKEN_INPUT, "mission_id": "different_mission", "rate": 0.00003, "quantity": 1000.0, @@ -987,13 +833,13 @@ def test_multiple_missions_isolation( mock_servicer._validate_and_store_cost(different_mission_cost) # Get costs for original mission - future_get = thread_pool.submit(asyncio.run, client.get_filtered()) + future_get = thread_pool.submit(asyncio.run, client.list()) - method_desc = service_desc.methods_by_name["GetCosts"] + method_desc = service_desc.methods_by_name["ListCosts"] _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.GetCosts(request, context) + response = mock_servicer.ListCosts(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") diff --git a/tests/services/filesystem/mock_filesystem_servicer.py b/tests/services/filesystem/mock_filesystem_servicer.py index de6f74eb..fd8dc0d9 100644 --- a/tests/services/filesystem/mock_filesystem_servicer.py +++ b/tests/services/filesystem/mock_filesystem_servicer.py @@ -7,18 +7,16 @@ import grpc from agentic_mesh_protocol.filesystem.v1 import ( - filesystem_pb2, - filesystem_service_pb2_grpc, + filesystem_messages_pb2, + filesystem_service_pb2_grpc, filesystem_dto_pb2, ) +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 from google.protobuf import struct_pb2 from google.protobuf.json_format import MessageToDict from pydantic import ValidationError from digitalkin.logger import logger -from digitalkin.services.filesystem.filesystem_strategy import ( - FileFilter, - FilesystemRecord, -) +from digitalkin.models.services.filesystem import FilesystemRecord, FileFilter, FileType, FileStatus class MockFilesystemServicer(filesystem_service_pb2_grpc.FilesystemServiceServicer): @@ -31,7 +29,8 @@ def __init__(self) -> None: super().__init__() self.files: dict[str, dict[str, FilesystemRecord]] = {} # context -> {id: file_data} - def _model_to_proto(self, model: dict[str, Any]) -> filesystem_pb2.File: + @staticmethod + def __model_to_proto(model: dict[str, Any]) -> filesystem_messages_pb2.File: """Convert a database model to a proto message. Args: @@ -40,27 +39,25 @@ def _model_to_proto(self, model: dict[str, Any]) -> filesystem_pb2.File: Returns: File: The proto message """ - file_type = getattr(filesystem_pb2.FileType, model["file_type"], filesystem_pb2.FileType.FILE_TYPE_UNSPECIFIED) - status = getattr(filesystem_pb2.FileStatus, model["status"], filesystem_pb2.FileStatus.FILE_STATUS_UNSPECIFIED) metadata = struct_pb2.Struct() if model.get("metadata"): metadata.update(model["metadata"]) - return filesystem_pb2.File( - file_id=str(model.get("id")) if model.get("id") else "", + return filesystem_messages_pb2.File( + id=str(model.get("id")) if model.get("id") else "", context=str(model.get("context")) if model.get("context") else "", name=model.get("name"), - file_type=file_type, + type=model["type"].to_proto(), content_type=model.get("content_type"), size_bytes=model.get("size_bytes"), checksum=model.get("checksum"), metadata=metadata, storage_uri=model.get("storage_uri"), - file_url=model.get("file_url"), - status=status, + url=model.get("url"), + status=model["status"].to_proto(), ) - def _generate_url(self, context: str, name: str) -> str: + def __generate_url(self, context: str, name: str) -> str: """Generate a fake URL for a file. Args: @@ -73,14 +70,43 @@ def _generate_url(self, context: str, name: str) -> str: random_id = "".join(secrets.choice(self.alphabet) for _ in range(8)) return f"https://storage.example.com/{context}/{random_id}/{name}" + @staticmethod + def __matches_filters(file_data: FilesystemRecord, filters: FileFilter) -> bool: + """Check if a file matches the given filters. + + Args: + file_data: The file data to check + filters: The filter criteria + + Returns: + bool: True if the file matches all filters, False otherwise + """ + if filters.names and file_data.name not in filters.names: + return False + if filters.ids and file_data.id not in filters.ids: + return False + if filters.types and file_data.type not in filters.types: + return False + if filters.status and file_data.status != filters.status: + return False + if filters.content_type_prefix and not file_data.content_type.startswith(filters.content_type_prefix): + return False + if filters.min_size_bytes and file_data.size_bytes < filters.min_size_bytes: + return False + if filters.max_size_bytes and file_data.size_bytes > filters.max_size_bytes: + return False + if filters.prefix and not file_data.name.startswith(filters.prefix): + return False + return not (filters.content_type and file_data.content_type != filters.content_type) + def UploadFiles( - self, request: filesystem_pb2.UploadFilesRequest, grpc_context: grpc.ServicerContext - ) -> filesystem_pb2.UploadFilesResponse: + self, request: filesystem_dto_pb2.UploadFilesRequest, grpc_context: grpc.ServicerContext + ) -> filesystem_dto_pb2.UploadFilesResponse: """Upload multiple files to the mock filesystem. Args: request: The UploadFilesRequest containing the files to upload - context: The gRPC context + grpc_context: The gRPC context Returns: filesystem_pb2.UploadFilesResponse: The response containing the uploaded files @@ -104,46 +130,46 @@ def UploadFiles( logger.warning(msg) grpc_context.set_code(grpc.StatusCode.ALREADY_EXISTS) grpc_context.set_details(msg) - results.append(filesystem_pb2.FileResult(error=msg)) + results.append(filesystem_messages_pb2.FileResult(error=msg)) total_failed += 1 continue try: # Create the file data - url = self._generate_url(context, name) + url = self.__generate_url(context, name) file_id = secrets.token_hex(16) datetime.now(timezone.utc) file_data_obj = FilesystemRecord( id=file_id, context=context, name=name, - file_type=filesystem_pb2.FileType.Name(file_data.file_type), + type=FileType.from_proto(file_data.type), content_type=file_data.content_type or "application/octet-stream", size_bytes=len(file_data.content), checksum=secrets.token_hex(32), # Mock checksum metadata=MessageToDict(file_data.metadata) if file_data.HasField("metadata") else None, storage_uri=url, - file_url=url, - status=filesystem_pb2.FileStatus.Name(file_data.status), + url=url, + status=FileStatus.from_proto(file_data.status), ) # Store the file self.files[context][file_id] = file_data_obj logger.debug(f"Uploaded file {name} to context {context}") - file_proto = self._model_to_proto(file_data_obj.model_dump()) - results.append(filesystem_pb2.FileResult(file=file_proto)) + file_proto = self.__model_to_proto(file_data_obj.model_dump()) + results.append(filesystem_messages_pb2.FileResult(file=file_proto)) total_uploaded += 1 except Exception as e: msg = f"Error uploading file {name}: {e!s}" logger.exception(msg) - results.append(filesystem_pb2.FileResult(error=msg)) + results.append(filesystem_messages_pb2.FileResult(error=msg)) total_failed += 1 - return filesystem_pb2.UploadFilesResponse( - results=results, - total_uploaded=total_uploaded, - total_failed=total_failed, + bulk = bulk_pb2.BulkResponse(total_process=total_uploaded, total_failed=total_failed) + return filesystem_dto_pb2.UploadFilesResponse( + result=results, + bulk=bulk ) except ValidationError as e: @@ -151,29 +177,29 @@ def UploadFiles( logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INVALID_ARGUMENT) grpc_context.set_details(msg) - return filesystem_pb2.UploadFilesResponse() + return filesystem_dto_pb2.UploadFilesResponse() except Exception as e: msg = f"Unexpected error in UploadFiles: {e!s}" logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INTERNAL) grpc_context.set_details(msg) - return filesystem_pb2.UploadFilesResponse() + return filesystem_dto_pb2.UploadFilesResponse() def GetFile( - self, request: filesystem_pb2.GetFileRequest, grpc_context: grpc.ServicerContext - ) -> filesystem_pb2.GetFileResponse: + self, request: filesystem_dto_pb2.GetFileRequest, grpc_context: grpc.ServicerContext + ) -> filesystem_dto_pb2.GetFileResponse: """Get a file by ID from the mock filesystem. Args: request: The GetFileRequest containing the ID of the file to get - context: The gRPC context + grpc_context: The gRPC context Returns: filesystem_pb2.GetFileResponse: The response containing the file """ try: context = request.context - file_id = request.file_id + file_id = request.id # Check if context exists if context not in self.files: @@ -181,36 +207,42 @@ def GetFile( logger.warning(msg) grpc_context.set_code(grpc.StatusCode.NOT_FOUND) grpc_context.set_details(msg) - return filesystem_pb2.GetFileResponse() + result = filesystem_messages_pb2.FileResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), message=msg), + success=False) + return filesystem_dto_pb2.GetFileResponse(result=result) # Check if file exists if file_id not in self.files[context]: msg = f"File with ID {file_id} does not exist in context {context}" logger.warning(msg) grpc_context.set_code(grpc.StatusCode.NOT_FOUND) - grpc_context.set_details(msg) - return filesystem_pb2.GetFileResponse() + result = filesystem_messages_pb2.FileResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), message=msg), + success=False) + return filesystem_dto_pb2.GetFileResponse(result=result) # Return the file file_data = self.files[context][file_id] - file_proto = self._model_to_proto(file_data.model_dump()) + file_proto = self.__model_to_proto(file_data.model_dump()) + result = filesystem_messages_pb2.FileResult(file=file_proto, success=True) - return filesystem_pb2.GetFileResponse(file=file_proto) + return filesystem_dto_pb2.GetFileResponse(result=result) except Exception as e: msg = f"Unexpected error in GetFile: {e!s}" logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INTERNAL) grpc_context.set_details(msg) - return filesystem_pb2.GetFileResponse() + result = filesystem_messages_pb2.FileResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL), message=msg), + success=False) + return filesystem_dto_pb2.GetFileResponse(result=result) - def GetFiles( - self, request: filesystem_pb2.GetFilesRequest, grpc_context: grpc.ServicerContext - ) -> filesystem_pb2.GetFilesResponse: + def ListFiles( + self, request: filesystem_dto_pb2.ListFilesRequest, grpc_context: grpc.ServicerContext + ) -> filesystem_dto_pb2.ListFilesResponse: """Get files based on filter criteria. Args: request: The GetFilesRequest containing filter criteria - context: The gRPC context + grpc_context: The gRPC context Returns: filesystem_pb2.GetFilesResponse: The response containing matching files @@ -223,79 +255,49 @@ def GetFiles( if context not in self.files: # Return empty list rather than error, as this is a common case logger.debug(f"Context {context} does not exist or is empty") - return filesystem_pb2.GetFilesResponse(files=[], total_count=0) + bulk = bulk_pb2.BulkResponse(total_process=0, total_failed=0) + return filesystem_dto_pb2.ListFilesResponse(result=[], bulk=bulk) # Apply filters filtered_files = [] logger.info(f"Filters: {filters}") logger.info(f"Files: {self.files[context]}") for file_data in self.files[context].values(): - if self._matches_filters(file_data, filters): - file_proto = self._model_to_proto(file_data.model_dump()) + if self.__matches_filters(file_data, filters): + file_proto = self.__model_to_proto(file_data.model_dump()) filtered_files.append(file_proto) # Apply pagination total_count = len(filtered_files) - start_idx = request.offset - end_idx = start_idx + request.list_size + start_idx = request.pagination.offset + end_idx = start_idx + request.pagination.limit paginated_files = filtered_files[start_idx:end_idx] - return filesystem_pb2.GetFilesResponse(files=paginated_files, total_count=total_count) + result_files = [filesystem_messages_pb2.FileResult(file=file, identifier='1') for file in paginated_files] + bulk = bulk_pb2.BulkResponse(total_process=total_count, total_failed=0) + return filesystem_dto_pb2.ListFilesResponse(result=result_files, bulk=bulk) except Exception as e: msg = f"Unexpected error in GetFiles: {e!s}" logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INTERNAL) grpc_context.set_details(msg) - return filesystem_pb2.GetFilesResponse(files=[], total_count=0) - - def _matches_filters(self, file_data: FilesystemRecord, filters: FileFilter) -> bool: - """Check if a file matches the given filters. - - Args: - file_data: The file data to check - filters: The filter criteria - - Returns: - bool: True if the file matches all filters, False otherwise - """ - if filters.names and file_data.name not in filters.names: - return False - if filters.file_ids and file_data.id not in filters.file_ids: - return False - # Handle both prefixed (FILE_TYPE_X) and non-prefixed (X) file types - if filters.file_types: - prefixed_types = [f"FILE_TYPE_{ft}" if not ft.startswith("FILE_TYPE_") else ft for ft in filters.file_types] - if file_data.file_type not in filters.file_types and file_data.file_type not in prefixed_types: - return False - # Handle both prefixed (FILE_STATUS_X) and non-prefixed (X) status - if filters.status: - prefixed_status = f"FILE_STATUS_{filters.status}" if not filters.status.startswith("FILE_STATUS_") else filters.status - if file_data.status != filters.status and file_data.status != prefixed_status: - return False - if filters.content_type_prefix and not file_data.content_type.startswith(filters.content_type_prefix): - return False - if filters.min_size_bytes and file_data.size_bytes < filters.min_size_bytes: - return False - if filters.max_size_bytes and file_data.size_bytes > filters.max_size_bytes: - return False - if filters.prefix and not file_data.name.startswith(filters.prefix): - return False - return not (filters.content_type and file_data.content_type != filters.content_type) + bulk = bulk_pb2.BulkResponse(total_process=0, total_failed=0) + return filesystem_dto_pb2.ListFilesResponse(result=[], bulk=bulk) def UpdateFile( - self, request: filesystem_pb2.UpdateFileRequest, grpc_context: grpc.ServicerContext - ) -> filesystem_pb2.UpdateFileResponse: + self, request: filesystem_dto_pb2.UpdateFileRequest, grpc_context: grpc.ServicerContext + ) -> filesystem_dto_pb2.UpdateFileResponse: """Update a file in the mock filesystem. Args: request: The UpdateFileRequest containing the file to update - context: The gRPC context + grpc_context: The gRPC context Returns: filesystem_pb2.UpdateFileResponse: The response containing the updated file """ try: context = request.context - file_id = request.file_id + file_id = request.id # Check if context exists if context not in self.files: @@ -303,7 +305,7 @@ def UpdateFile( logger.warning(msg) grpc_context.set_code(grpc.StatusCode.NOT_FOUND) grpc_context.set_details(msg) - return filesystem_pb2.UpdateFileResponse() + return filesystem_dto_pb2.UpdateFileResponse() # Check if file exists if file_id not in self.files[context]: @@ -311,50 +313,50 @@ def UpdateFile( logger.warning(msg) grpc_context.set_code(grpc.StatusCode.NOT_FOUND) grpc_context.set_details(msg) - return filesystem_pb2.UpdateFileResponse() + return filesystem_dto_pb2.UpdateFileResponse() # Update the file data file_data = self.files[context][file_id] if request.content: file_data.size_bytes = len(request.content) file_data.checksum = secrets.token_hex(32) # Mock checksum - if request.file_type: - file_data.file_type = filesystem_pb2.FileType.Name(request.file_type) + if request.type: + file_data.type = FileType.from_proto(request.type) if request.content_type: file_data.content_type = request.content_type if request.metadata: file_data.metadata = MessageToDict(request.metadata) if request.new_name: file_data.name = request.new_name - file_data.storage_uri = self._generate_url(context, request.new_name) + file_data.storage_uri = self.__generate_url(context, request.new_name) if request.status: - file_data.status = filesystem_pb2.FileStatus.Name(request.status) + file_data.status = FileStatus.from_proto(request.status) # Convert to proto and return - file_proto = self._model_to_proto(file_data.model_dump()) + file_proto = self.__model_to_proto(file_data.model_dump()) - return filesystem_pb2.UpdateFileResponse(result=filesystem_pb2.FileResult(file=file_proto)) + return filesystem_dto_pb2.UpdateFileResponse(result=filesystem_messages_pb2.FileResult(file=file_proto)) except ValidationError as e: msg = f"Validation error: {e!s}" logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INVALID_ARGUMENT) grpc_context.set_details(msg) - return filesystem_pb2.UpdateFileResponse() + return filesystem_dto_pb2.UpdateFileResponse() except Exception as e: msg = f"Unexpected error in UpdateFile: {e!s}" logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INTERNAL) grpc_context.set_details(msg) - return filesystem_pb2.UpdateFileResponse() + return filesystem_dto_pb2.UpdateFileResponse() def DeleteFiles( - self, request: filesystem_pb2.DeleteFilesRequest, grpc_context: grpc.ServicerContext - ) -> filesystem_pb2.DeleteFilesResponse: + self, request: filesystem_dto_pb2.DeleteFilesRequest, grpc_context: grpc.ServicerContext + ) -> filesystem_dto_pb2.DeleteFilesResponse: """Delete multiple files from the mock filesystem. Args: request: The DeleteFilesRequest containing filter criteria - context: The gRPC context + grpc_context: The gRPC context Returns: filesystem_pb2.DeleteFilesResponse: The response indicating success or failure @@ -370,25 +372,30 @@ def DeleteFiles( logger.warning(msg) grpc_context.set_code(grpc.StatusCode.NOT_FOUND) grpc_context.set_details(msg) - return filesystem_pb2.DeleteFilesResponse() + return filesystem_dto_pb2.DeleteFilesResponse() results = {} total_deleted = 0 total_failed = 0 + deleted_files = [] # Store file data for response # Find files matching the filters files_to_delete = [] for file_id, file_data in self.files[context].items(): - if self._matches_filters(file_data, filters): - files_to_delete.append(file_id) + if self.__matches_filters(file_data, filters): + files_to_delete.append((file_id, file_data)) # Delete the files - for file_id in files_to_delete: + for file_id, file_data in files_to_delete: try: + # Store file proto before deletion for response + file_proto = self.__model_to_proto(file_data.model_dump()) + deleted_files.append(file_proto) + if permanent: del self.files[context][file_id] else: - self.files[context][file_id].status = "FILE_STATUS_DELETED" + self.files[context][file_id].status = FileStatus.DELETED results[file_id] = True total_deleted += 1 except Exception as e: @@ -397,14 +404,15 @@ def DeleteFiles( results[file_id] = False total_failed += 1 - return filesystem_pb2.DeleteFilesResponse( - results=results, - total_deleted=total_deleted, - total_failed=total_failed, + bulk = bulk_pb2.BulkResponse(total_process=total_deleted, total_failed=total_failed) + file_result = [filesystem_messages_pb2.FileResult(file=file, identifier='-1') for file in deleted_files] + return filesystem_dto_pb2.DeleteFilesResponse( + result=file_result, + bulk=bulk ) except Exception as e: msg = f"Unexpected error in DeleteFiles: {e!s}" logger.exception(msg) grpc_context.set_code(grpc.StatusCode.INTERNAL) grpc_context.set_details(msg) - return filesystem_pb2.DeleteFilesResponse() + return filesystem_dto_pb2.DeleteFilesResponse() diff --git a/tests/services/filesystem/test_default_filesystem.py b/tests/services/filesystem/test_default_filesystem.py index 5400eca5..3efeeb28 100644 --- a/tests/services/filesystem/test_default_filesystem.py +++ b/tests/services/filesystem/test_default_filesystem.py @@ -3,14 +3,11 @@ from pathlib import Path import pytest +from agentic_mesh_protocol.pagination.v1.pagination_pb2 import PaginationRequest +from digitalkin.exception.filesystem import FilesystemServiceError +from digitalkin.models.services.filesystem import FileType, UploadFileData, FileStatus, FilesystemRecord, FileFilter from digitalkin.services.filesystem import DefaultFilesystem -from digitalkin.services.filesystem.filesystem_strategy import ( - FileFilter, - FilesystemRecord, - FilesystemServiceError, - UploadFileData, -) @pytest.fixture @@ -39,10 +36,10 @@ def file_metadata() -> dict: return { "context": "test_setup", "name": "test_file.txt", - "file_type": "DOCUMENT", + "type": FileType.DOCUMENT, "content_type": "text/plain", "metadata": {"key": "value"}, - "status": "ACTIVE", + "status": FileStatus.ACTIVE, } @@ -69,14 +66,14 @@ async def test_upload_files_success( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) # Upload the file - files, total_uploaded, total_failed = await filesystem.upload_files([upload_file]) + files, total_uploaded, total_failed = await filesystem.upload([upload_file]) assert len(files) == 1 assert total_uploaded == 1 assert total_failed == 0 @@ -86,12 +83,12 @@ async def test_upload_files_success( assert isinstance(file_data, FilesystemRecord) assert file_data.context == file_metadata["context"] assert file_data.name == file_metadata["name"] - assert file_data.file_type == file_metadata["file_type"] + assert file_data.type == file_metadata["type"] assert file_data.content_type == file_metadata["content_type"] assert file_data.metadata == file_metadata["metadata"] assert file_data.status == file_metadata["status"] assert file_data.storage_uri is not None - assert file_data.file_url is not None + assert file_data.url is not None # Verify the file exists on disk file_path = Path(filesystem._get_context_temp_dir(file_metadata["context"]), file_metadata["name"]) @@ -112,26 +109,26 @@ async def test_get_file_success( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) - files, _, _ = await filesystem.upload_files([upload_file]) + files, _, _ = await filesystem.upload([upload_file]) file_id = files[0].id # Get the file - file_data = await filesystem.get_file(file_id) + file_data = await filesystem.get(file_id) assert isinstance(file_data, FilesystemRecord) assert file_data.id == file_id assert file_data.context == file_metadata["context"] assert file_data.name == file_metadata["name"] - assert file_data.file_type == file_metadata["file_type"] + assert file_data.type == file_metadata["type"] assert file_data.content_type == file_metadata["content_type"] assert file_data.metadata == file_metadata["metadata"] assert file_data.status == file_metadata["status"] assert file_data.storage_uri is not None - assert file_data.file_url is not None + assert file_data.url is not None async def test_get_files_success( self, filesystem: DefaultFilesystem, sample_file_data: bytes, file_metadata: dict @@ -149,7 +146,7 @@ async def test_get_files_success( UploadFileData( content=sample_file_data, name=name, - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, @@ -157,19 +154,13 @@ async def test_get_files_success( for name in file_names ] - _files, _, _ = await filesystem.upload_files(upload_files) + _files, _, _ = await filesystem.upload(upload_files) # Create filter criteria - filters = FileFilter(file_types=[file_metadata["file_type"]]) + filters = FileFilter(types=[file_metadata["type"]]) # Get the files - result_files, total_count = await filesystem.get_files( - filters, - list_size=10, - offset=0, - order="created_at:desc", - include_content=False, - ) + result_files, total_count = await filesystem.list(filters, include_content=False) assert len(result_files) == 3 assert total_count == 3 @@ -178,12 +169,12 @@ async def test_get_files_success( assert isinstance(file_data, FilesystemRecord) assert file_data.context == file_metadata["context"] assert file_data.name in file_names - assert file_data.file_type == file_metadata["file_type"] + assert file_data.type == file_metadata["type"] assert file_data.content_type == file_metadata["content_type"] assert file_data.metadata == file_metadata["metadata"] assert file_data.status == file_metadata["status"] assert file_data.storage_uri is not None - assert file_data.file_url is not None + assert file_data.url is not None async def test_update_file_success( self, filesystem: DefaultFilesystem, sample_file_data: bytes, file_metadata: dict @@ -199,36 +190,36 @@ async def test_update_file_success( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) - files, _, _ = await filesystem.upload_files([upload_file]) + files, _, _ = await filesystem.upload([upload_file]) file_id = files[0].id # Update the file updated_content = b"Updated content" - updated_file = await filesystem.update_file( + updated_file = await filesystem.update( file_id, content=updated_content, - file_type="DOCUMENT", + type=FileType.DOCUMENT, content_type="text/plain", metadata={"new_key": "new_value"}, new_name="updated_file.txt", - status="ACTIVE", + status=FileStatus.ACTIVE, ) assert isinstance(updated_file, FilesystemRecord) assert updated_file.id == file_id assert updated_file.context == file_metadata["context"] assert updated_file.name == "updated_file.txt" - assert updated_file.file_type == "DOCUMENT" + assert updated_file.type == FileType.DOCUMENT assert updated_file.content_type == "text/plain" assert updated_file.metadata == {"new_key": "new_value"} - assert updated_file.status == "ACTIVE" + assert updated_file.status == FileStatus.ACTIVE assert updated_file.storage_uri is not None - assert updated_file.file_url is not None + assert updated_file.url is not None # Verify the file content was updated file_path = Path(filesystem._get_context_temp_dir(file_metadata["context"]), "updated_file.txt") @@ -251,7 +242,7 @@ async def test_delete_files_success( UploadFileData( content=sample_file_data, name=name, - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, @@ -259,17 +250,17 @@ async def test_delete_files_success( for name in file_names ] - files, _, _ = await filesystem.upload_files(upload_files) + files, _, _ = await filesystem.upload(upload_files) file_ids = [file_data.id for file_data in files] # Create filter criteria - filters = FileFilter(file_types=[file_metadata["file_type"]]) + filters = FileFilter(types=[file_metadata["type"]]) # Delete the files - results, total_deleted, total_failed = await filesystem.delete_files( + results, total_deleted, total_failed = await filesystem.delete( filters, permanent=True, - force=False, + _force=False, ) assert len(results) == 3 @@ -291,7 +282,7 @@ async def test_get_file_nonexistent(self, filesystem: DefaultFilesystem) -> None filesystem: DefaultFilesystem instance """ with pytest.raises(FilesystemServiceError): - await filesystem.get_file("nonexistent_file_id") + await filesystem.get("nonexistent_file_id") async def test_update_file_nonexistent(self, filesystem: DefaultFilesystem, sample_file_data: bytes) -> None: """Test updating a non-existent file. @@ -301,14 +292,14 @@ async def test_update_file_nonexistent(self, filesystem: DefaultFilesystem, samp sample_file_data: Sample file data """ with pytest.raises(FilesystemServiceError): - await filesystem.update_file( + await filesystem.update( "nonexistent_file_id", content=sample_file_data, - file_type="DOCUMENT", + type=FileType.DOCUMENT, content_type="text/plain", metadata={"key": "value"}, new_name="updated_file.txt", - status="ACTIVE", + status=FileStatus.ACTIVE, ) async def test_delete_files_nonexistent(self, filesystem: DefaultFilesystem) -> None: @@ -319,15 +310,15 @@ async def test_delete_files_nonexistent(self, filesystem: DefaultFilesystem) -> """ # Create filter criteria for non-existent files filters = FileFilter( - file_types=["DOCUMENT"], - status="ACTIVE", + types=[FileType.DOCUMENT], + status=FileStatus.ACTIVE, ) # Attempt to delete the files - results, total_deleted, total_failed = await filesystem.delete_files( + results, total_deleted, total_failed = await filesystem.delete( filters, permanent=True, - force=False, + _force=False, ) assert len(results) == 0 @@ -348,16 +339,16 @@ async def test_upload_files_duplicate_error( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) - await filesystem.upload_files([upload_file]) + await filesystem.upload([upload_file]) # Try to upload the same file again with pytest.raises(FilesystemServiceError): - await filesystem.upload_files([upload_file]) + await filesystem.upload([upload_file]) async def test_upload_files_replace_existing( self, filesystem: DefaultFilesystem, sample_file_data: bytes, file_metadata: dict @@ -373,24 +364,24 @@ async def test_upload_files_replace_existing( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) - await filesystem.upload_files([upload_file]) + await filesystem.upload([upload_file]) # Upload the same file with replace_if_exists=True new_content = b"New content" upload_file_replace = UploadFileData( content=new_content, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=True, ) - files, total_uploaded, total_failed = await filesystem.upload_files([upload_file_replace]) + files, total_uploaded, total_failed = await filesystem.upload([upload_file_replace]) assert len(files) == 1 assert total_uploaded == 1 assert total_failed == 0 @@ -415,7 +406,7 @@ async def test_get_files_with_filters( UploadFileData( content=sample_file_data, name="file1.txt", - file_type="DOCUMENT", + type=FileType.DOCUMENT, content_type="text/plain", metadata={"key": "value1"}, replace_if_exists=False, @@ -423,7 +414,7 @@ async def test_get_files_with_filters( UploadFileData( content=sample_file_data, name="file2.txt", - file_type="IMAGE", + type=FileType.IMAGE, content_type="image/png", metadata={"key": "value2"}, replace_if_exists=False, @@ -431,41 +422,41 @@ async def test_get_files_with_filters( UploadFileData( content=sample_file_data, name="file3.txt", - file_type="DOCUMENT", + type=FileType.DOCUMENT, content_type="text/plain", metadata={"key": "value3"}, replace_if_exists=False, ), ] - files, _, _ = await filesystem.upload_files(files_to_upload) + files, _, _ = await filesystem.upload(files_to_upload) # Update one file to ARCHIVED status - await filesystem.update_file(files[1].id, status="ARCHIVED") + await filesystem.update(files[1].id, status=FileStatus.ARCHIVED) # Test filtering by type - filters = FileFilter(file_types=["DOCUMENT"]) - result_files, total_count = await filesystem.get_files(filters) + filters = FileFilter(types=[FileType.DOCUMENT]) + result_files, total_count = await filesystem.list(filters) assert len(result_files) == 2 assert total_count == 2 - assert all(f.file_type == "DOCUMENT" for f in result_files) + assert all(f.type == FileType.DOCUMENT for f in result_files) # Test filtering by status - filters = FileFilter(status="ARCHIVED") - result_files, total_count = await filesystem.get_files(filters) + filters = FileFilter(status=FileStatus.ARCHIVED) + result_files, total_count = await filesystem.list(filters) assert len(result_files) == 1 assert total_count == 1 - assert result_files[0].status == "ARCHIVED" + assert result_files[0].status == FileStatus.ARCHIVED # Test filtering by content type filters = FileFilter(content_type="image/png") - result_files, total_count = await filesystem.get_files(filters) + result_files, total_count = await filesystem.list(filters) assert len(result_files) == 1 assert total_count == 1 assert result_files[0].content_type == "image/png" # Test filtering by name prefix filters = FileFilter(prefix="file1") - result_files, total_count = await filesystem.get_files(filters) + result_files, total_count = await filesystem.list(filters) assert len(result_files) == 1 assert total_count == 1 assert result_files[0].name == "file1.txt" @@ -485,30 +476,30 @@ async def test_get_files_pagination( UploadFileData( content=sample_file_data, name=f"file{i}.txt", - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) for i in range(5) ] - await filesystem.upload_files(files_to_upload) + await filesystem.upload(files_to_upload) # Test pagination with list_size=2 filters = FileFilter() # First page - result_files, total_count = await filesystem.get_files(filters, list_size=2, offset=0) + result_files, total_count = await filesystem.list(filters, pagination=PaginationRequest(limit=2, offset=0)) assert len(result_files) == 2 assert total_count == 5 # Second page - result_files, total_count = await filesystem.get_files(filters, list_size=2, offset=2) + result_files, total_count = await filesystem.list(filters, pagination=PaginationRequest(limit=2, offset=2)) assert len(result_files) == 2 assert total_count == 5 # Last page - result_files, total_count = await filesystem.get_files(filters, list_size=2, offset=4) + result_files, total_count = await filesystem.list(filters, pagination=PaginationRequest(limit=2, offset=4)) assert len(result_files) == 1 assert total_count == 5 @@ -526,24 +517,24 @@ async def test_delete_files_soft_delete( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) - files, _, _ = await filesystem.upload_files([upload_file]) + files, _, _ = await filesystem.upload([upload_file]) file_id = files[0].id # Soft delete the file - filters = FileFilter(file_ids=[file_id]) - results, total_deleted, total_failed = await filesystem.delete_files(filters, permanent=False) + filters = FileFilter(ids=[file_id]) + results, total_deleted, total_failed = await filesystem.delete(filters, permanent=False) assert len(results) == 1 assert total_deleted == 1 assert total_failed == 0 assert results[file_id] is True # Verify the file still exists but is marked as deleted - file_data = await filesystem.get_file(file_id) - assert file_data.status == "DELETED" + file_data = await filesystem.get(file_id) + assert file_data.status == FileStatus.DELETED file_path = Path(filesystem._get_context_temp_dir(file_metadata["context"]), file_metadata["name"]) assert file_path.exists() diff --git a/tests/services/filesystem/test_grpc_filesystem.py b/tests/services/filesystem/test_grpc_filesystem.py index 05bd1f76..823720ca 100644 --- a/tests/services/filesystem/test_grpc_filesystem.py +++ b/tests/services/filesystem/test_grpc_filesystem.py @@ -10,23 +10,21 @@ import grpc_testing import pytest from agentic_mesh_protocol.filesystem.v1 import ( - filesystem_pb2, + filesystem_messages_pb2, filesystem_service_pb2, - filesystem_service_pb2_grpc, + filesystem_service_pb2_grpc, filesystem_dto_pb2, ) +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 +from agentic_mesh_protocol.pagination.v1.pagination_pb2 import PaginationRequest from google.protobuf import struct_pb2 from grpc.framework.foundation import logging_pool -from mock_filesystem_servicer import MockFilesystemServicer -from tests.fixtures.grpc_fixtures import FakeContext +from digitalkin.exception.filesystem import FilesystemServiceError from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode -from digitalkin.services.filesystem.filesystem_strategy import ( - FileFilter, - FilesystemRecord, - FilesystemServiceError, - UploadFileData, -) -from digitalkin.services.filesystem.grpc_filesystem import GrpcFilesystem +from digitalkin.models.services.filesystem import FileType, FileStatus, UploadFileData, FilesystemRecord, FileFilter +from digitalkin.services.filesystem.filesystem_grpc import GrpcFilesystem +from mock_filesystem_servicer import MockFilesystemServicer +from tests.fixtures.grpc_fixtures import FakeContext service_instance = MockFilesystemServicer() service_name = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] @@ -122,14 +120,14 @@ def file_metadata() -> dict: "id": f"file_{secrets.token_hex(8)}", "context": "setup", "name": name, - "file_type": "DOCUMENT", + "type": FileType.DOCUMENT, "content_type": "text/plain", "size_bytes": 40, "checksum": "a1b2c3d4e5f6", "metadata": {"key": "value"}, "storage_uri": f"gs://test-bucket/setup/{name}", - "file_url": f"https://storage.example.com/setup/{name}", - "status": "UPLOADING", + "url": f"https://storage.example.com/setup/{name}", + "status": FileStatus.ACTIVE, } @@ -162,14 +160,14 @@ def test_upload_files_success( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) # Start the client call in a separate thread - future = client_execution_thread_pool.submit(asyncio.run, client.upload_files([upload_file])) + future = client_execution_thread_pool.submit(asyncio.run, client.upload([upload_file])) # Get the service and method descriptor service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] @@ -186,26 +184,22 @@ def test_upload_files_success( metadata_struct = None # Create a response with all required fields - file_result = filesystem_pb2.FileResult( - file=filesystem_pb2.File( - file_id=file_metadata["id"], - context=file_metadata["context"], - name=file_metadata["name"], - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), - content_type=file_metadata["content_type"], - size_bytes=file_metadata["size_bytes"], - checksum=file_metadata["checksum"], - metadata=metadata_struct, - storage_uri=file_metadata["storage_uri"], - file_url=file_metadata["file_url"], - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), - ) - ) - response = filesystem_pb2.UploadFilesResponse( - results=[file_result], - total_uploaded=1, - total_failed=0, + file = filesystem_messages_pb2.File( + id=file_metadata["id"], + context=file_metadata["context"], + name=file_metadata["name"], + type=file_metadata["type"].to_proto(), + content_type=file_metadata["content_type"], + size_bytes=file_metadata["size_bytes"], + checksum=file_metadata["checksum"], + metadata=metadata_struct, + storage_uri=file_metadata["storage_uri"], + url=file_metadata["url"], + status=file_metadata["status"].to_proto(), ) + file_result = [filesystem_messages_pb2.FileResult(file=file, identifier='1')] + bulk = bulk_pb2.BulkResponse(total_process=1, total_failed=0) + response = filesystem_dto_pb2.UploadFilesResponse(bulk=bulk, result=file_result) # Use grpc_testing to send the response back to the client rpc.send_initial_metadata(()) @@ -228,22 +222,22 @@ def test_upload_files_success( assert file_data.context == file_metadata["context"] assert file_data.name == file_metadata["name"] # Accept either enum-prefixed or plain values depending on transport layer - assert file_data.file_type in { - file_metadata["file_type"], - "FILE_TYPE_" + file_metadata["file_type"], + assert file_data.type in { + file_metadata["type"], + file_metadata["type"], } assert file_data.content_type == file_metadata["content_type"] assert file_data.size_bytes == file_metadata["size_bytes"] assert file_data.checksum == file_metadata["checksum"] assert file_data.metadata == file_metadata["metadata"] assert file_data.storage_uri == file_metadata["storage_uri"] - assert file_data.file_url == file_metadata["file_url"] + assert file_data.url == file_metadata["url"] assert file_data.status in { file_metadata["status"], - "FILE_STATUS_" + file_metadata["status"], + file_metadata["status"], } assert file_data.storage_uri is not None - assert file_data.file_url is not None + assert file_data.url is not None assert file_data.size_bytes == len(sample_file_data) assert file_data.checksum is not None @@ -271,29 +265,29 @@ def test_upload_files_duplicate_error( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) # Upload the file first time - future = client_execution_thread_pool.submit(asyncio.run, client.upload_files([upload_file])) + future = client_execution_thread_pool.submit(asyncio.run, client.upload([upload_file])) service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] method_desc = service_desc.methods_by_name["UploadFiles"] _, _, rpc = test_channel.take_unary_unary(method_desc) metadata_struct = struct_pb2.Struct() metadata_struct.update(file_metadata["metadata"]) - upload_request = filesystem_pb2.UploadFilesRequest( + upload_request = filesystem_dto_pb2.UploadFilesRequest( files=[ - filesystem_pb2.UploadFileData( + filesystem_messages_pb2.UploadFileData( context=file_metadata["context"], name=file_metadata["name"], - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), + type=file_metadata["type"].to_proto(), content_type=file_metadata["content_type"], content=sample_file_data, metadata=metadata_struct, - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + status=file_metadata["status"].to_proto(), replace_if_exists=False, ) ] @@ -304,7 +298,7 @@ def test_upload_files_duplicate_error( future.result() # Try to upload the same file again - future = client_execution_thread_pool.submit(asyncio.run, client.upload_files([upload_file])) + future = client_execution_thread_pool.submit(asyncio.run, client.upload([upload_file])) _, _, rpc = test_channel.take_unary_unary(method_desc) response = mock_servicer.UploadFiles(upload_request, FakeContext()) rpc.send_initial_metadata(()) @@ -344,25 +338,25 @@ def test_get_file_success( if file_metadata["metadata"]: metadata_struct.update(file_metadata["metadata"]) - upload_request = filesystem_pb2.UploadFilesRequest( + upload_request = filesystem_dto_pb2.UploadFilesRequest( files=[ - filesystem_pb2.UploadFileData( + filesystem_messages_pb2.UploadFileData( context=file_metadata["context"], name=file_metadata["name"], - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), + type=file_metadata["type"].to_proto(), content_type=file_metadata["content_type"], content=sample_file_data, metadata=metadata_struct, - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + status=file_metadata["status"].to_proto(), replace_if_exists=False, ) ] ) upload_response = mock_servicer.UploadFiles(upload_request, FakeContext()) - file_id = upload_response.results[0].file.file_id + file_id = upload_response.result[0].file.id # Start the client call to get the file - future = client_execution_thread_pool.submit(asyncio.run, client.get_file(file_id)) + future = client_execution_thread_pool.submit(asyncio.run, client.get(file_id)) # Get the service and method descriptor service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] @@ -372,9 +366,9 @@ def test_get_file_success( _, _, rpc = test_channel.take_unary_unary(method_desc) # Create a request object for the mock servicer - get_request = filesystem_pb2.GetFileRequest( + get_request = filesystem_dto_pb2.GetFileRequest( context=file_metadata["context"], - file_id=file_id, + id=file_id, include_content=False, ) @@ -388,12 +382,12 @@ def test_get_file_success( assert result.id == file_id assert result.context == file_metadata["context"] assert result.name == file_metadata["name"] - assert result.file_type == "FILE_TYPE_" + file_metadata["file_type"] + assert result.type == file_metadata["type"] assert result.content_type == file_metadata["content_type"] assert result.metadata == file_metadata["metadata"] - assert result.status == "FILE_STATUS_" + file_metadata["status"] + assert result.status == file_metadata["status"] assert result.storage_uri is not None - assert result.file_url is not None + assert result.url is not None assert result.size_bytes == len(sample_file_data) assert result.checksum is not None @@ -411,7 +405,7 @@ def test_get_file_not_found( client: GrpcFilesystem client for testing test_channel: Mock gRPC channel """ - future = client_execution_thread_pool.submit(asyncio.run, client.get_file("nonexistent_file_id")) + future = client_execution_thread_pool.submit(asyncio.run, client.get("nonexistent_file_id")) service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] method_desc = service_desc.methods_by_name["GetFile"] _, _, rpc = test_channel.take_unary_unary(method_desc) @@ -422,13 +416,13 @@ def test_get_file_not_found( future.result() -class TestGetFiles: +class TestListFiles: """Tests for Filesystem.get_files() method.""" @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_get_files_success( + def test_list_files_success( self, client: GrpcFilesystem, test_channel: grpc_testing.Channel, @@ -455,22 +449,22 @@ def test_get_files_success( metadata_struct.update(file_metadata["metadata"]) upload_files = [ - filesystem_pb2.UploadFileData( + filesystem_messages_pb2.UploadFileData( context=file_metadata["context"], name=name, - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), + type=file_metadata["type"].to_proto(), content_type=file_metadata["content_type"], content=sample_file_data, metadata=metadata_struct, - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + status=file_metadata["status"].to_proto(), replace_if_exists=False, ) for name in file_names ] - upload_request = filesystem_pb2.UploadFilesRequest(files=upload_files) + upload_request = filesystem_dto_pb2.UploadFilesRequest(files=upload_files) upload_response = mock_servicer.UploadFiles(upload_request, FakeContext()) - file_ids = [result.file.file_id for result in upload_response.results] + file_ids = [result.file.id for result in upload_response.result] # Create filter criteria filters = FileFilter() @@ -478,39 +472,35 @@ def test_get_files_success( # Start the client call to get files future = client_execution_thread_pool.submit( asyncio.run, - client.get_files( + client.list( filters, - list_size=10, - offset=0, - order="created_at:desc", + pagination=PaginationRequest(limit=10, offset=0, order="created_at:desc"), include_content=False, ), ) # Get the service and method descriptor service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] - method_desc = service_desc.methods_by_name["GetFiles"] + method_desc = service_desc.methods_by_name["ListFiles"] # Intercept the pending unary-unary call _, _request, rpc = test_channel.take_unary_unary(method_desc) # Create a request object for the mock servicer - get_request = filesystem_pb2.GetFilesRequest( + list_request = filesystem_dto_pb2.ListFilesRequest( context=file_metadata["context"], - filters=filesystem_pb2.FileFilter( + filters=filesystem_messages_pb2.FileFilter( context=file_metadata["context"], - file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + types=[file_metadata["type"].to_proto()], + status=file_metadata["status"].to_proto(), ), - list_size=10, - offset=0, - order="created_at:desc", + pagination=PaginationRequest(limit=10, offset=0, order="created_at:desc"), include_content=False, ) # Use grpc_testing to send the response back to the client rpc.send_initial_metadata(()) - rpc.terminate(mock_servicer.GetFiles(get_request, FakeContext()), (), grpc.StatusCode.OK, "") + rpc.terminate(mock_servicer.ListFiles(list_request, FakeContext()), (), grpc.StatusCode.OK, "") # Verify the client call returns a list of FilesystemRecord result = future.result(timeout=5.0) @@ -523,43 +513,41 @@ def test_get_files_success( assert isinstance(file_data, FilesystemRecord) assert file_data.context == file_metadata["context"] assert file_data.name in file_names - assert file_data.file_type == "FILE_TYPE_" + file_metadata["file_type"] + assert file_data.type == file_metadata["type"] assert file_data.content_type == file_metadata["content_type"] assert file_data.metadata == file_metadata["metadata"] - assert file_data.status == "FILE_STATUS_" + file_metadata["status"] + assert file_data.status == file_metadata["status"] assert file_data.storage_uri is not None - assert file_data.file_url is not None + assert file_data.url is not None assert file_data.size_bytes == len(sample_file_data) assert file_data.checksum is not None assert file_data.id in file_ids # Test empty context case empty_filters = FileFilter( - file_types=[file_metadata["file_type"]], - status="UPLOADING", + types=[file_metadata["type"]], + status=FileStatus.UPLOADING, ) future = client_execution_thread_pool.submit( asyncio.run, - client.get_files( + client.list( empty_filters, - list_size=10, - offset=0, + pagination=PaginationRequest(limit=10, offset=0) ), ) _, _, rpc = test_channel.take_unary_unary(method_desc) - filesystem_pb2.GetFilesRequest( + filesystem_dto_pb2.ListFilesRequest( context="nonexistent_context", - filters=filesystem_pb2.FileFilter( + filters=filesystem_messages_pb2.FileFilter( context="nonexistent_context", - file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + types=[file_metadata["type"].to_proto()], + status=file_metadata["status"].to_proto(), ), - list_size=10, - offset=0, + pagination=PaginationRequest(limit=10, offset=0) ) - empty_response = filesystem_pb2.GetFilesResponse(files=[], total_count=0) + empty_response = filesystem_dto_pb2.ListFilesResponse(bulk=bulk_pb2.BulkResponse(total_process=0, total_failed=0), result=[]) rpc.send_initial_metadata(()) rpc.terminate(empty_response, (), grpc.StatusCode.OK, "") @@ -600,35 +588,35 @@ def test_update_file_success( if file_metadata["metadata"]: metadata_struct.update(file_metadata["metadata"]) - upload_request = filesystem_pb2.UploadFilesRequest( + upload_request = filesystem_dto_pb2.UploadFilesRequest( files=[ - filesystem_pb2.UploadFileData( + filesystem_messages_pb2.UploadFileData( context=file_metadata["context"], name=file_metadata["name"], - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), + type=file_metadata["type"].to_proto(), content_type=file_metadata["content_type"], content=sample_file_data, metadata=metadata_struct, - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + status=file_metadata["status"].to_proto(), replace_if_exists=False, ) ] ) upload_response = mock_servicer.UploadFiles(upload_request, FakeContext()) - file_id = upload_response.results[0].file.file_id + file_id = upload_response.result[0].file.id # Start the client call to update the file updated_content = b"Updated content" future = client_execution_thread_pool.submit( asyncio.run, - client.update_file( + client.update( file_id, content=updated_content, - file_type="DOCUMENT", + type=FileType.DOCUMENT, content_type="text/plain", metadata={"new_key": "new_value"}, new_name="updated_file.txt", - status="ACTIVE", + status=FileStatus.ACTIVE, ), ) @@ -640,15 +628,15 @@ def test_update_file_success( _, _, rpc = test_channel.take_unary_unary(method_desc) # Create a request object for the mock servicer - update_request = filesystem_pb2.UpdateFileRequest( + update_request = filesystem_dto_pb2.UpdateFileRequest( context=file_metadata["context"], - file_id=file_id, + id=file_id, content=updated_content, - file_type=GrpcFilesystem._file_type_to_enum("DOCUMENT"), + type=FileType.DOCUMENT.to_proto(), content_type="text/plain", metadata=struct_pb2.Struct(fields={"new_key": struct_pb2.Value(string_value="new_value")}), new_name="updated_file.txt", - status=GrpcFilesystem._file_status_to_enum("ACTIVE"), + status=FileStatus.ACTIVE.to_proto(), ) # Use the mock servicer to handle the request @@ -664,12 +652,12 @@ def test_update_file_success( assert result.id == file_id assert result.context == file_metadata["context"] assert result.name == "updated_file.txt" - assert result.file_type == "FILE_TYPE_DOCUMENT" + assert result.type == FileType.DOCUMENT assert result.content_type == "text/plain" assert result.metadata == {"new_key": "new_value"} - assert result.status == "FILE_STATUS_ACTIVE" + assert result.status == FileStatus.ACTIVE assert result.storage_uri is not None - assert result.file_url is not None + assert result.url is not None @pytest.mark.grpc @pytest.mark.integration @@ -687,10 +675,10 @@ def test_update_file_not_found( """ future = client_execution_thread_pool.submit( asyncio.run, - client.update_file( + client.update( "nonexistent_file_id", content=b"new content", - file_type="DOCUMENT", + type=FileType.DOCUMENT, content_type="text/plain", ), ) @@ -737,32 +725,32 @@ def test_delete_files_success( metadata_struct.update(file_metadata["metadata"]) upload_files = [ - filesystem_pb2.UploadFileData( + filesystem_messages_pb2.UploadFileData( context=file_metadata["context"], name=name, - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), + type=file_metadata["type"].to_proto(), content_type=file_metadata["content_type"], content=sample_file_data, metadata=metadata_struct, - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + status=file_metadata["status"].to_proto(), replace_if_exists=False, ) for name in file_names ] - upload_request = filesystem_pb2.UploadFilesRequest(files=upload_files) + upload_request = filesystem_dto_pb2.UploadFilesRequest(files=upload_files) upload_response = mock_servicer.UploadFiles(upload_request, FakeContext()) - file_ids = [result.file.file_id for result in upload_response.results] + file_ids = [result.file.id for result in upload_response.result] # Create filter criteria filters = FileFilter( - file_types=[file_metadata["file_type"]], + types=[file_metadata["type"]], ) # Start the client call to delete files future = client_execution_thread_pool.submit( asyncio.run, - client.delete_files( + client.delete( filters, permanent=True, force=False, @@ -777,12 +765,12 @@ def test_delete_files_success( _, _, rpc = test_channel.take_unary_unary(method_desc) # Create a request object for the mock servicer - delete_request = filesystem_pb2.DeleteFilesRequest( + delete_request = filesystem_dto_pb2.DeleteFilesRequest( context=file_metadata["context"], - filters=filesystem_pb2.FileFilter( + filters=filesystem_messages_pb2.FileFilter( context=file_metadata["context"], - file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + types=[file_metadata["type"].to_proto()], + status=file_metadata["status"].to_proto(), ), permanent=True, force=False, @@ -821,13 +809,13 @@ def test_delete_files_not_found( test_channel: Mock gRPC channel """ filters = FileFilter( - file_types=["DOCUMENT"], - status="ACTIVE", + types=[FileType.DOCUMENT], + status=FileStatus.ACTIVE, ) future = client_execution_thread_pool.submit( asyncio.run, - client.delete_files( + client.delete( filters, permanent=True, force=False, @@ -838,11 +826,7 @@ def test_delete_files_not_found( _, _, rpc = test_channel.take_unary_unary(method_desc) # Mock servicer returns empty results for non-existent context - response = filesystem_pb2.DeleteFilesResponse( - results={}, - total_deleted=0, - total_failed=0, - ) + response = filesystem_dto_pb2.DeleteFilesResponse(bulk=bulk_pb2.BulkResponse(total_process=0, total_failed=0), result=[]) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -876,14 +860,14 @@ def test_server_error( upload_file = UploadFileData( content=b"Sample content", name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) # Start the client call - future = client_execution_thread_pool.submit(asyncio.run, client.upload_files([upload_file])) + future = client_execution_thread_pool.submit(asyncio.run, client.upload([upload_file])) # Get the service and method descriptor service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] @@ -929,14 +913,14 @@ def test_file_status_handling( upload_file = UploadFileData( content=sample_file_data, name=file_metadata["name"], - file_type=file_metadata["file_type"], + type=file_metadata["type"], content_type=file_metadata["content_type"], metadata=file_metadata["metadata"], replace_if_exists=False, ) # Upload the file - future = client_execution_thread_pool.submit(asyncio.run, client.upload_files([upload_file])) + future = client_execution_thread_pool.submit(asyncio.run, client.upload([upload_file])) service_desc = filesystem_service_pb2.DESCRIPTOR.services_by_name["FilesystemService"] method_desc = service_desc.methods_by_name["UploadFiles"] _, _, rpc = test_channel.take_unary_unary(method_desc) @@ -944,16 +928,16 @@ def test_file_status_handling( metadata_struct = struct_pb2.Struct() metadata_struct.update(file_metadata["metadata"]) - upload_request = filesystem_pb2.UploadFilesRequest( + upload_request = filesystem_dto_pb2.UploadFilesRequest( files=[ - filesystem_pb2.UploadFileData( + filesystem_messages_pb2.UploadFileData( context=file_metadata["context"], name=file_metadata["name"], - file_type=GrpcFilesystem._file_type_to_enum(file_metadata["file_type"]), + type=file_metadata["type"].to_proto(), content_type=file_metadata["content_type"], content=sample_file_data, metadata=metadata_struct, - status=GrpcFilesystem._file_status_to_enum(file_metadata["status"]), + status=file_metadata["status"].to_proto(), replace_if_exists=False, ) ] @@ -968,25 +952,25 @@ def test_file_status_handling( assert len(files) == 1 assert total_uploaded == 1 assert total_failed == 0 - assert files[0].status == "FILE_STATUS_" + file_metadata["status"] + assert files[0].status == file_metadata["status"] file_id = files[0].id # Update the file status future = client_execution_thread_pool.submit( asyncio.run, - client.update_file( + client.update( file_id, - status="ACTIVE", + status=FileStatus.ACTIVE, ), ) method_desc = service_desc.methods_by_name["UpdateFile"] _, _, rpc = test_channel.take_unary_unary(method_desc) - update_request = filesystem_pb2.UpdateFileRequest( + update_request = filesystem_dto_pb2.UpdateFileRequest( context=file_metadata["context"], - file_id=file_id, - status=GrpcFilesystem._file_status_to_enum("ACTIVE"), + id=file_id, + status=FileStatus.ACTIVE.to_proto(), ) response = mock_servicer.UpdateFile(update_request, FakeContext()) rpc.send_initial_metadata(()) @@ -994,15 +978,15 @@ def test_file_status_handling( update_result = future.result() assert isinstance(update_result, FilesystemRecord) - assert update_result.status == "FILE_STATUS_ACTIVE" + assert update_result.status == FileStatus.ACTIVE # Get the file and verify status - future = client_execution_thread_pool.submit(asyncio.run, client.get_file(file_id)) + future = client_execution_thread_pool.submit(asyncio.run, client.get(file_id)) method_desc = service_desc.methods_by_name["GetFile"] _, _, rpc = test_channel.take_unary_unary(method_desc) - get_request = filesystem_pb2.GetFileRequest( + get_request = filesystem_dto_pb2.GetFileRequest( context=file_metadata["context"], - file_id=file_id, + id=file_id, ) response = mock_servicer.GetFile(get_request, FakeContext()) rpc.send_initial_metadata(()) @@ -1010,18 +994,18 @@ def test_file_status_handling( get_result = future.result() assert isinstance(get_result, FilesystemRecord) - assert get_result.status == "FILE_STATUS_ACTIVE" + assert get_result.status == FileStatus.ACTIVE # Delete the file (soft delete) filters = FileFilter( context="setup", - file_types=[file_metadata["file_type"]], - status="ACTIVE", + types=[file_metadata["type"]], + status=FileStatus.ACTIVE, ) future = client_execution_thread_pool.submit( asyncio.run, - client.delete_files( + client.delete( filters, permanent=False, force=False, @@ -1030,15 +1014,15 @@ def test_file_status_handling( # Build proto filter manually to avoid context ID conversion # The mock servicer expects raw context ("setup") not ID ("setup:1") - filters_proto = filesystem_pb2.FileFilter( + filters_proto = filesystem_messages_pb2.FileFilter( context=file_metadata["context"], - file_types=[GrpcFilesystem._file_type_to_enum(file_metadata["file_type"])], - status=GrpcFilesystem._file_status_to_enum("ACTIVE"), + types=[file_metadata["type"].to_proto()], + status=FileStatus.ACTIVE.to_proto(), ) method_desc = service_desc.methods_by_name["DeleteFiles"] _, _, rpc = test_channel.take_unary_unary(method_desc) - delete_request = filesystem_pb2.DeleteFilesRequest( + delete_request = filesystem_dto_pb2.DeleteFilesRequest( context=file_metadata["context"], filters=filters_proto, permanent=False, diff --git a/tests/services/registry/mock_registry_servicer.py b/tests/services/registry/mock_registry_servicer.py index 8b009a5a..dfb59dec 100644 --- a/tests/services/registry/mock_registry_servicer.py +++ b/tests/services/registry/mock_registry_servicer.py @@ -3,14 +3,15 @@ from typing import Any import grpc +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 from agentic_mesh_protocol.registry.v1 import ( - registry_enums_pb2, - registry_models_pb2, - registry_requests_pb2, + registry_dto_pb2, + registry_messages_pb2, registry_service_pb2_grpc, ) from digitalkin.logger import logger +from digitalkin.services.registry import ModuleType, ModuleStatus class MockRegistryServicer(registry_service_pb2_grpc.RegistryServiceServicer): @@ -22,7 +23,8 @@ def __init__(self) -> None: # module_id -> module data self.registered_modules: dict[str, dict[str, Any]] = {} - def _create_module_descriptor(self, module_data: dict[str, Any]) -> registry_models_pb2.ModuleDescriptor: + @staticmethod + def __create_module_descriptor(module_data: dict[str, Any]) -> registry_messages_pb2.ModuleDescriptor: """Create a ModuleDescriptor from module data. Args: @@ -31,29 +33,22 @@ def _create_module_descriptor(self, module_data: dict[str, Any]) -> registry_mod Returns: ModuleDescriptor protobuf message. """ - # Map module type string to proto enum - type_mapping = { - "archetype": registry_enums_pb2.MODULE_TYPE_ARCHETYPE, - "tool": registry_enums_pb2.MODULE_TYPE_TOOL, - } - module_type = type_mapping.get(module_data.get("module_type", ""), registry_enums_pb2.MODULE_TYPE_UNSPECIFIED) - - return registry_models_pb2.ModuleDescriptor( - id=module_data["module_id"], - name=module_data.get("name", module_data["module_id"]), - module_type=module_type, + return registry_messages_pb2.ModuleDescriptor( + id=module_data["id"], + name=module_data.get("name", module_data["id"]), + type=module_data["type"].to_proto(), address=module_data["address"], port=module_data["port"], version=module_data["version"], documentation=module_data.get("documentation", ""), - status=module_data.get("status", registry_enums_pb2.MODULE_STATUS_READY), + status=module_data["status"].to_proto(), ) def RegisterModule( self, - request: registry_requests_pb2.RegisterModuleRequest, + request: registry_dto_pb2.RegisterModuleRequest, context: grpc.ServicerContext, - ) -> registry_requests_pb2.RegisterModuleResponse: + ) -> registry_dto_pb2.RegisterModuleResponse: """Register a module with the registry. Note: In the new proto, RegisterModule updates address/port/version for existing modules. @@ -72,26 +67,27 @@ def RegisterModule( if module_id not in self.registered_modules: # New proto expects module to already exist in registry logger.warning("Mock: Module '%s' not found for registration", module_id) - return registry_requests_pb2.RegisterModuleResponse() + result = registry_messages_pb2.RegistryResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False) + return registry_dto_pb2.RegisterModuleResponse(result=result) # Update the module info self.registered_modules[module_id].update({ "address": request.address, "port": request.port, "version": request.version, - "status": registry_enums_pb2.MODULE_STATUS_ACTIVE, + "status": ModuleStatus.ACTIVE, }) logger.debug("Mock: Module %s registered at %s:%d", module_id, request.address, request.port) - return registry_requests_pb2.RegisterModuleResponse( - module=self._create_module_descriptor(self.registered_modules[module_id]) - ) + result = registry_messages_pb2.RegistryResult(module_descriptor=self.__create_module_descriptor(self.registered_modules[module_id]), + success=True) + return registry_dto_pb2.RegisterModuleResponse(result=result) def Heartbeat( self, - request: registry_requests_pb2.HeartbeatRequest, + request: registry_dto_pb2.HeartbeatRequest, context: grpc.ServicerContext, - ) -> registry_requests_pb2.HeartbeatResponse: + ) -> registry_dto_pb2.HeartbeatResponse: """Process heartbeat from a module. Args: @@ -110,17 +106,17 @@ def Heartbeat( logger.warning("Mock: %s", message) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(message) - return registry_requests_pb2.HeartbeatResponse(status=registry_enums_pb2.MODULE_STATUS_UNSPECIFIED) + return registry_dto_pb2.HeartbeatResponse(status=ModuleStatus.UNSPECIFIED.to_proto()) # Update status to ACTIVE and return - self.registered_modules[module_id]["status"] = registry_enums_pb2.MODULE_STATUS_ACTIVE - return registry_requests_pb2.HeartbeatResponse(status=registry_enums_pb2.MODULE_STATUS_ACTIVE) + self.registered_modules[module_id]["status"] = ModuleStatus.ACTIVE.to_proto() + return registry_dto_pb2.HeartbeatResponse(status=ModuleStatus.ACTIVE.to_proto()) - def DiscoverModules( + def SearchModules( self, - request: registry_requests_pb2.DiscoverModulesRequest, + request: registry_dto_pb2.SearchModulesRequest, context: grpc.ServicerContext, - ) -> registry_requests_pb2.DiscoverModulesResponse: + ) -> registry_dto_pb2.SearchModulesResponse: """Discover modules based on search criteria. Args: @@ -136,29 +132,29 @@ def DiscoverModules( # Filter by query (name match) if request.query: - results = [m for m in results if request.query in m.get("name", m["module_id"])] + results = [m for m in results if request.query in m.get("name", m["id"])] # Filter by module types if specified if request.module_types: type_strings = [] for mt in request.module_types: - if mt == registry_enums_pb2.MODULE_TYPE_ARCHETYPE: - type_strings.append("archetype") - elif mt == registry_enums_pb2.MODULE_TYPE_TOOL: - type_strings.append("tool") + mt = ModuleType.from_proto(mt) + if mt == ModuleType.ARCHETYPE: + type_strings.append(ModuleType.ARCHETYPE) + elif mt == ModuleType.TOOL: + type_strings.append(ModuleType.TOOL) if type_strings: - results = [m for m in results if m.get("module_type", "") in type_strings] + results = [m for m in results if m.get("type", "") in type_strings] logger.debug("Mock: Found %d matching modules", len(results)) - return registry_requests_pb2.DiscoverModulesResponse( - modules=[self._create_module_descriptor(m) for m in results] - ) + results = [registry_messages_pb2.RegistryResult(module_descriptor=self.__create_module_descriptor(m), success=True) for m in results] + return registry_dto_pb2.SearchModulesResponse(result=results) def GetModule( self, - request: registry_requests_pb2.GetModuleRequest, + request: registry_dto_pb2.GetModuleRequest, context: grpc.ServicerContext, - ) -> registry_models_pb2.ModuleDescriptor: + ) -> registry_dto_pb2.GetModuleResponse: """Get detailed information about a specific module. Args: @@ -176,43 +172,34 @@ def GetModule( logger.warning("Mock: %s", message) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(message) - return registry_models_pb2.ModuleDescriptor() + result = registry_messages_pb2.RegistryResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), message=message), + success=False) + return registry_dto_pb2.GetModuleResponse(result=result) - return self._create_module_descriptor(self.registered_modules[request.module_id]) + result = registry_messages_pb2.RegistryResult(module_descriptor=self.__create_module_descriptor(self.registered_modules[ + request.module_id]), success=True) + return registry_dto_pb2.GetModuleResponse(result=result) - def DiscoverSetups( - self, - request: registry_requests_pb2.DiscoverSetupsRequest, - context: grpc.ServicerContext, - ) -> registry_requests_pb2.DiscoverSetupsResponse: - """Discover setups based on search criteria. + def GetModuleStatus(self, request: registry_dto_pb2.GetModuleStatusRequest, + context: grpc.ServicerContext) -> registry_dto_pb2.GetModuleStatusResponse: + """Get the current status of a module. Args: - request: The discover setups request. + request: The get module status request. context: The gRPC context. Returns: - DiscoverSetupsResponse with matching setups. + GetModuleStatusResponse with the module's current status. """ - logger.debug("Mock: Discovering setups with query '%s'", request.query) - # Not implemented in mock - return empty - return registry_requests_pb2.DiscoverSetupsResponse() + logger.debug("Mock: Getting status for module: %s", request.module_id) - def GetSetup( - self, - request: registry_requests_pb2.GetSetupRequest, - context: grpc.ServicerContext, - ) -> registry_models_pb2.SetupDescriptor: - """Get detailed information about a specific setup. - - Args: - request: The get setup request. - context: The gRPC context. + # Check if module exists + if request.module_id not in self.registered_modules: + message = f"Module {request.module_id} not found in registry" + logger.warning("Mock: %s", message) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(message) + return registry_dto_pb2.GetModuleStatusResponse(status=ModuleStatus.UNSPECIFIED.to_proto()) - Returns: - SetupDescriptor with setup details. - """ - logger.debug("Mock: Getting setup: %s", request.setup_id) - # Not implemented in mock - return empty - context.set_code(grpc.StatusCode.NOT_FOUND) - return registry_models_pb2.SetupDescriptor() + status = self.registered_modules[request.module_id].get("status", ModuleStatus.ARCHIVED.to_proto()) + return registry_dto_pb2.GetModuleStatusResponse(status=status) diff --git a/tests/services/registry/test_grpc_registry.py b/tests/services/registry/test_grpc_registry.py index f0435068..d6038fe3 100644 --- a/tests/services/registry/test_grpc_registry.py +++ b/tests/services/registry/test_grpc_registry.py @@ -16,19 +16,16 @@ import grpc_testing import pytest from agentic_mesh_protocol.registry.v1 import ( - registry_enums_pb2, registry_service_pb2, registry_service_pb2_grpc, ) -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -from tests.services.registry.mock_registry_servicer import MockRegistryServicer +from digitalkin.exception.registry import RegistryServiceError from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode -from digitalkin.models.services.registry import RegistryModuleStatus, RegistryModuleType -from digitalkin.services.registry.exceptions import ( - RegistryServiceError, -) -from digitalkin.services.registry.grpc_registry import GrpcRegistry +from digitalkin.models.services.modules import ModuleType, ModuleStatus +from digitalkin.services.registry.registry_grpc import GrpcRegistry +from tests.fixtures.grpc_fixtures import FakeContext, AsyncStubWrapper +from tests.services.registry.mock_registry_servicer import MockRegistryServicer # Set timeout for all tests in this file (20 seconds) pytestmark = pytest.mark.timeout(20) @@ -122,13 +119,13 @@ async def _test_exec_grpc_query(self, query_endpoint, request): # ============================================================================ -class TestDiscoverById: - """Tests for the discover_by_id() method.""" +class TestGet: + """Tests for the get() method.""" @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_discover_by_id_success( + def test_module_success( self, client: GrpcRegistry, test_channel: grpc_testing.Channel, @@ -140,24 +137,25 @@ def test_discover_by_id_success( # Pre-register a module mock_servicer.registered_modules[module_id] = { - "module_id": module_id, - "module_type": "tool", + "id": module_id, + "type": ModuleType.TOOL, "name": "TestModule", "address": "localhost", "port": 50051, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } # Get the method descriptor method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name["GetModule"] # Execute client call in thread pool - future = thread_pool.submit(asyncio.run, client.discover_by_id(module_id)) + future = thread_pool.submit(asyncio.run, client.get(module_id)) # Intercept the call _, request, rpc = test_channel.take_unary_unary(method_desc) + # Verify request assert request.module_id == module_id @@ -174,16 +172,16 @@ def test_discover_by_id_success( # Verify result assert result is not None - assert result.module_id == module_id - assert result.module_type == RegistryModuleType.TOOL + assert result.id == module_id + assert result.type == ModuleType.TOOL assert result.address == "localhost" assert result.port == 50051 - assert result.module_name == "TestModule" + assert result.name == "TestModule" @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_discover_by_id_not_found( + def test_module_not_found( self, client: GrpcRegistry, test_channel: grpc_testing.Channel, @@ -195,7 +193,7 @@ def test_discover_by_id_not_found( method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name["GetModule"] - future = thread_pool.submit(asyncio.run, client.discover_by_id(module_id)) + future = thread_pool.submit(asyncio.run, client.get(module_id)) _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -228,26 +226,26 @@ def test_search_by_name( """Test searching modules by name.""" # Pre-register modules mock_servicer.registered_modules["mod1"] = { - "module_id": "mod1", - "module_type": "tool", + "id": "mod1", + "type": ModuleType.TOOL, "name": "SearchableModule", "address": "localhost", "port": 50051, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } mock_servicer.registered_modules["mod2"] = { - "module_id": "mod2", - "module_type": "archetype", + "id": "mod2", + "type": ModuleType.ARCHETYPE, "name": "OtherModule", "address": "localhost", "port": 50052, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ - "DiscoverModules" + "SearchModules" ] future = thread_pool.submit(asyncio.run, client.search(name="Searchable")) @@ -255,7 +253,7 @@ def test_search_by_name( _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.DiscoverModules(request, context) + response = mock_servicer.SearchModules(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -263,8 +261,8 @@ def test_search_by_name( results = future.result(timeout=1.0) assert len(results) == 1 - assert results[0].module_id == "mod1" - assert results[0].module_name == "SearchableModule" + assert results[0].id == "mod1" + assert results[0].name == "SearchableModule" @pytest.mark.grpc @pytest.mark.integration @@ -277,34 +275,34 @@ def test_search_by_type( ) -> None: """Test searching modules by type.""" mock_servicer.registered_modules["mod1"] = { - "module_id": "mod1", - "module_type": "tool", + "id": "mod1", + "type": ModuleType.TOOL, "name": "Tool1", "address": "localhost", "port": 50051, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } mock_servicer.registered_modules["mod2"] = { - "module_id": "mod2", - "module_type": "archetype", + "id": "mod2", + "type": ModuleType.ARCHETYPE, "name": "Archetype1", "address": "localhost", "port": 50052, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ - "DiscoverModules" + "SearchModules" ] - future = thread_pool.submit(asyncio.run, client.search(module_type="tool")) + future = thread_pool.submit(asyncio.run, client.search(module_type=ModuleType.TOOL)) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.DiscoverModules(request, context) + response = mock_servicer.SearchModules(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -312,7 +310,7 @@ def test_search_by_type( results = future.result(timeout=1.0) assert len(results) == 1 - assert results[0].module_type == RegistryModuleType.TOOL + assert results[0].type == ModuleType.TOOL @pytest.mark.grpc @pytest.mark.integration @@ -325,7 +323,7 @@ def test_search_no_results( ) -> None: """Test search with no matching results.""" method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ - "DiscoverModules" + "SearchModules" ] future = thread_pool.submit(asyncio.run, client.search(name="NonExistent")) @@ -333,7 +331,7 @@ def test_search_no_results( _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() - response = mock_servicer.DiscoverModules(request, context) + response = mock_servicer.SearchModules(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -361,13 +359,13 @@ def test_register_success( # Pre-register module (new proto requires module to exist) mock_servicer.registered_modules[module_id] = { - "module_id": module_id, - "module_type": "tool", + "id": module_id, + "type": ModuleType.TOOL, "name": "ExistingModule", "address": "old-host", "port": 50050, "version": "0.9.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name[ @@ -400,7 +398,7 @@ def test_register_success( result = future.result(timeout=1.0) assert result is not None - assert result.module_id == module_id + assert result.id == module_id assert result.address == "localhost" assert result.port == 50053 @@ -462,16 +460,16 @@ def test_get_status_success( module_id = "module_001" mock_servicer.registered_modules[module_id] = { - "module_id": module_id, - "module_type": "tool", + "id": module_id, + "type": ModuleType.TOOL, "name": "TestModule", "address": "localhost", "port": 50051, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } - method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name["GetModule"] + method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name["GetModuleStatus"] future = thread_pool.submit(asyncio.run, client.get_status(module_id)) @@ -485,8 +483,36 @@ def test_get_status_success( result = future.result(timeout=1.0) - assert result.module_id == module_id - assert result.status == RegistryModuleStatus.READY + assert result == ModuleStatus.READY + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_get_status_not_found( + self, + client: GrpcRegistry, + test_channel: grpc_testing.Channel, + mock_servicer: MockRegistryServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successfully getting module status.""" + module_id = "nonexistent_module" + method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name["GetModuleStatus"] + + future = thread_pool.submit(asyncio.run, client.get_status(module_id)) + + _, request, rpc = test_channel.take_unary_unary(method_desc) + + context = FakeContext() + response = mock_servicer.GetModule(request, context) + + rpc.send_initial_metadata(()) + rpc.terminate(response, (), grpc.StatusCode.OK, "") + + # The error handler wraps RegistryModuleNotFoundError in RegistryServiceError + with pytest.raises(RegistryServiceError) as exc_info: + future.result(timeout=1.0) + assert module_id in str(exc_info.value) class TestHeartbeat: @@ -506,13 +532,13 @@ def test_heartbeat_success( module_id = "module_001" mock_servicer.registered_modules[module_id] = { - "module_id": module_id, - "module_type": "tool", + "id": module_id, + "type": ModuleType.TOOL, "name": "TestModule", "address": "localhost", "port": 50051, "version": "1.0.0", - "status": registry_enums_pb2.MODULE_STATUS_READY, + "status": ModuleStatus.READY, } method_desc = registry_service_pb2.DESCRIPTOR.services_by_name["RegistryService"].methods_by_name["Heartbeat"] @@ -531,7 +557,7 @@ def test_heartbeat_success( result = future.result(timeout=1.0) - assert result == RegistryModuleStatus.ACTIVE + assert result == ModuleStatus.ACTIVE @pytest.mark.grpc @pytest.mark.integration @@ -562,4 +588,4 @@ def test_heartbeat_not_found( result = future.result(timeout=1.0) # Returns UNSPECIFIED status when module not found - assert result == RegistryModuleStatus.UNSPECIFIED + assert result == ModuleStatus.UNSPECIFIED diff --git a/tests/services/setup/__init__.py b/tests/services/setup/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/setup/mock_setup_servicer.py b/tests/services/setup/mock_setup_servicer.py index 22a87177..9ecf7ccc 100644 --- a/tests/services/setup/mock_setup_servicer.py +++ b/tests/services/setup/mock_setup_servicer.py @@ -5,15 +5,15 @@ import string import grpc +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 from agentic_mesh_protocol.setup.v1 import ( - setup_pb2, - setup_service_pb2_grpc, + setup_messages_pb2, + setup_service_pb2_grpc, setup_dto_pb2, ) -from google.protobuf import json_format from pydantic import ValidationError from digitalkin.logger import logger -from digitalkin.services.setup.setup_strategy import SetupData, SetupVersionData +from digitalkin.models.services.setup import SetupData, SetupVersionData class MockSetupServicer(setup_service_pb2_grpc.SetupServiceServicer): @@ -34,20 +34,20 @@ def __init__(self) -> None: self.setup_versions = {} def CreateSetup( - self, request: setup_pb2.CreateSetupRequest, context: grpc.ServicerContext - ) -> setup_pb2.CreateSetupResponse: + self, request: setup_dto_pb2.CreateSetupRequest, context: grpc.ServicerContext + ) -> setup_dto_pb2.CreateSetupResponse: try: setup_data_version = SetupVersionData( id=request.current_setup_version.id, setup_id=request.current_setup_version.setup_id, version=request.current_setup_version.version, - creation_date=request.current_setup_version.creation_date.ToDatetime() or datetime.datetime.now(), # noqa: DTZ005 + created_at=request.current_setup_version.created_at.ToDatetime() or datetime.datetime.now(), # noqa: DTZ005 content=dict(request.current_setup_version.content), ) setup_data = SetupData( id=self._generate_id(), name=request.name, - organisation_id=request.organisation_id, + organization_id=request.organization_id, module_id=request.module_id, owner_id=request.owner_id, current_setup_version=setup_data_version, @@ -57,31 +57,38 @@ def CreateSetup( logger.exception(msg) context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details(msg) - return setup_pb2.CreateSetupResponse(success=False) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT), + message=msg)) + return setup_dto_pb2.CreateSetupResponse(result=result) self.setups[setup_data.id] = setup_data logger.debug("CREATE SETUP DATA %s:%s succesfull", setup_data.id, setup_data) - return setup_pb2.CreateSetupResponse(success=True) + result = setup_messages_pb2.SetupResult(success=True, setup=setup_messages_pb2.Setup(**self.setups[setup_data.id].model_dump())) + return setup_dto_pb2.CreateSetupResponse(result=result) - def GetSetup(self, request: setup_pb2.GetSetupRequest, context: grpc.ServicerContext) -> setup_pb2.GetSetupResponse: + def GetSetup(self, request: setup_dto_pb2.GetSetupRequest, context: grpc.ServicerContext) -> setup_dto_pb2.GetSetupResponse: logger.debug("GET SETUP setup_id = %s.", request.setup_id) if request.setup_id not in self.setups: msg = f"GET SETUP setup_id = {request.setup_id} | setup_id DOESN'T EXIST" logger.warning(msg) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(msg) - return setup_pb2.GetSetupResponse() - return setup_pb2.GetSetupResponse(setup=setup_pb2.Setup(**self.setups[request.setup_id].model_dump())) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), + message=msg)) + return setup_dto_pb2.GetSetupResponse(result=result) + result = setup_messages_pb2.SetupResult(setup=setup_messages_pb2.Setup(**self.setups[request.setup_id].model_dump()), success=True) + return setup_dto_pb2.GetSetupResponse(result=result) def UpdateSetup( - self, request: setup_pb2.UpdateSetupRequest, context: grpc.ServicerContext - ) -> setup_pb2.UpdateSetupResponse: + self, request: setup_dto_pb2.UpdateSetupRequest, context: grpc.ServicerContext + ) -> setup_dto_pb2.UpdateSetupResponse: if request.setup_id not in self.setups: msg = f"GET setup_id = {request.setup_id} | setup_id DOESN'T EXIST" logger.warning(msg) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(msg) - return setup_pb2.UpdateSetupResponse(success=False) + result = setup_messages_pb2.SetupResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), message=msg), success=False) + return setup_dto_pb2.UpdateSetupResponse(result=result) # Update only the fields that were explicitly set # For string fields, check if they're non-empty (proto3 default is empty string) @@ -96,153 +103,38 @@ def UpdateSetup( "id": request.current_setup_version.id, "setup_id": request.current_setup_version.setup_id, "version": request.current_setup_version.version, - "creation_date": request.current_setup_version.creation_date.ToDatetime() - if request.current_setup_version.HasField("creation_date") + "created_at": request.current_setup_version.created_at.ToDatetime() + if request.current_setup_version.HasField("created_at") else datetime.datetime.now(), # noqa: DTZ005 "content": dict(request.current_setup_version.content), } self.setups[request.setup_id].current_setup_version = SetupVersionData.model_validate(setup_version_dict) logger.debug("UPDATE SETUP DATA %s succesfull", request.setup_id) - return setup_pb2.UpdateSetupResponse(success=True) + result = setup_messages_pb2.SetupResult(setup=setup_messages_pb2.Setup(**self.setups[request.setup_id].model_dump()), success=True) + return setup_dto_pb2.UpdateSetupResponse(result=result) def DeleteSetup( - self, request: setup_pb2.DeleteSetupRequest, context: grpc.ServicerContext - ) -> setup_pb2.DeleteSetupResponse: + self, request: setup_dto_pb2.DeleteSetupRequest, context: grpc.ServicerContext + ) -> setup_dto_pb2.DeleteSetupResponse: if request.setup_id not in self.setups: msg = f"DELETE setup_id = {request.setup_id} | setup_id DOESN'T EXIST" logger.warning(msg) context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(msg) - return setup_pb2.DeleteSetupResponse(success=False) - + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), + message=msg)) + return setup_dto_pb2.DeleteSetupResponse(result=result) + result = setup_messages_pb2.SetupResult(setup=setup_messages_pb2.Setup(**self.setups[request.setup_id].model_dump()), success=True) del self.setups[request.setup_id] - return setup_pb2.DeleteSetupResponse(success=True) - - def CreateSetupVersion( - self, request: setup_pb2.CreateSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.CreateSetupVersionResponse: - try: - setup_data_version = SetupVersionData( - id=self._generate_id(), - setup_id=request.setup_id, - version=request.version, - creation_date=datetime.datetime.now(), # noqa: DTZ005 - content=dict(request.content), - ) - except ValidationError: - msg = "Validation failed for model SetupVersionData" - logger.warning(msg) - context.set_code(grpc.StatusCode.INVALID_ARGUMENT) - context.set_details(msg) - return setup_pb2.CreateSetupVersionResponse(success=False) - - if request.setup_id not in self.setup_versions: - self.setup_versions[request.setup_id] = {} - self.setup_versions[request.setup_id][setup_data_version.version] = setup_data_version - logger.debug("CREATE SETUP VERSION DATA %s:%s succesfull", request.setup_id, setup_data_version) - return setup_pb2.CreateSetupVersionResponse(success=True) - - def GetSetupVersion( - self, request: setup_pb2.GetSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.GetSetupVersionResponse: - logger.debug("GET SETUP VERSION setup_version_id = %s.", request.setup_version_id) - - # Search for the setup version with the matching ID - setup_version = None - for setup_versions in self.setup_versions.values(): - for version_data in setup_versions.values(): - if version_data.id == request.setup_version_id: - setup_version = version_data - break - if setup_version: - break - - if setup_version is None: - msg = f"GET SETUP VERSION setup_version_id = {request.setup_version_id} | name DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.GetSetupVersionResponse() - - return setup_pb2.GetSetupVersionResponse(setup_version=setup_pb2.SetupVersion(**setup_version.model_dump())) - - def SearchSetupVersions( - self, request: setup_pb2.SearchSetupVersionsRequest, context: grpc.ServicerContext - ) -> setup_pb2.SearchSetupVersionsResponse: - if request.setup_id is None or request.setup_id not in self.setup_versions: - msg = f"GET setup_id = {request.setup_id}: setup_id DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.SearchSetupVersionsResponse() - - query_setup_versions = self.setup_versions[request.setup_id] - if request.version: - query_setup_versions = {k: v for k, v in query_setup_versions.items() if request.version in k} - - return setup_pb2.SearchSetupVersionsResponse( - setup_versions=[setup_pb2.SetupVersion(**value.model_dump()) for value in query_setup_versions.values()] - ) - - def UpdateSetupVersion( - self, request: setup_pb2.UpdateSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.UpdateSetupVersionResponse: - # Search for the setup version with the matching ID - setup_version = None - for setup_versions in self.setup_versions.values(): - for version_data in setup_versions.values(): - if version_data.id == request.setup_version_id: - setup_version = version_data - break - if setup_version: - break - - if setup_version is None: - msg = "UPDATE setup_version_id = {request.setup_version_id}: setup_version_id DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.UpdateSetupVersionResponse(success=False) - - self.setup_versions[setup_version.setup_id][setup_version.version].content = json_format.MessageToDict( - request.content - ) - return setup_pb2.UpdateSetupVersionResponse(success=True) - - def DeleteSetupVersion( - self, request: setup_pb2.DeleteSetupVersionRequest, context: grpc.ServicerContext - ) -> setup_pb2.DeleteSetupVersionResponse: - # Search for the setup version with the matching ID - setup_version = None - for setup_versions in self.setup_versions.values(): - for version_data in setup_versions.values(): - if version_data.id == request.setup_version_id: - setup_version = version_data - break - if setup_version: - break - - if setup_version is None: - msg = f"DELETE name = {request.setup_version_id} | name DOESN'T EXIST" - logger.warning(msg) - context.set_code(grpc.StatusCode.NOT_FOUND) - context.set_details(msg) - return setup_pb2.DeleteSetupVersionResponse(success=False) - - # Delete only the specific version, not all versions for this setup - del self.setup_versions[setup_version.setup_id][setup_version.version] - # If this was the last version for this setup, remove the setup entry as well - if not self.setup_versions[setup_version.setup_id]: - del self.setup_versions[setup_version.setup_id] - return setup_pb2.DeleteSetupVersionResponse(success=True) + return setup_dto_pb2.DeleteSetupResponse(result=result) def ListSetups( - self, request: setup_pb2.ListSetupsRequest, context: grpc.ServicerContext - ) -> setup_pb2.ListSetupsResponse: + self, request: setup_dto_pb2.ListSetupsRequest, context: grpc.ServicerContext + ) -> setup_dto_pb2.ListSetupsResponse: """List setups with optional filtering and pagination. Args: - request: ListSetupsRequest with organisation_id, owner_id, limit, offset + request: ListSetupsRequest with organization_id, owner_id, limit, offset context: gRPC context Returns: @@ -253,8 +145,8 @@ def ListSetups( filtered_setups = list(self.setups.values()) # Apply filters - if request.organisation_id: - filtered_setups = [s for s in filtered_setups if s.organisation_id == request.organisation_id] + if request.organization_id: + filtered_setups = [s for s in filtered_setups if s.organization_id == request.organization_id] if request.owner_id: filtered_setups = [s for s in filtered_setups if s.owner_id == request.owner_id] @@ -263,18 +155,21 @@ def ListSetups( total_count = len(filtered_setups) # Apply pagination - offset = max(0, request.offset) - limit = request.limit if request.limit > 0 else len(filtered_setups) + offset = max(0, request.pagination.offset) + limit = request.pagination.limit if request.pagination.limit > 0 else len(filtered_setups) paginated_setups = filtered_setups[offset : offset + limit] # Convert to proto messages - setup_protos = [setup_pb2.Setup(**s.model_dump()) for s in paginated_setups] + setup_protos = [setup_messages_pb2.Setup(**s.model_dump()) for s in paginated_setups] logger.info(f"Listed {len(setup_protos)} setups (total: {total_count})") - return setup_pb2.ListSetupsResponse(setups=setup_protos, total_count=total_count) + result = [setup_messages_pb2.SetupResult(success=True, setup=setup) for setup in setup_protos] + bulk = bulk_pb2.BulkResponse(total_process=total_count, total_failed=0) + return setup_dto_pb2.ListSetupsResponse(result=result, bulk=bulk) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in ListSetups: {e}", exc_info=True) - return setup_pb2.ListSetupsResponse(setups=[], total_count=0) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL))) + return setup_dto_pb2.ListSetupsResponse(result=result) diff --git a/tests/services/setup/test_grpc_setup.py b/tests/services/setup/test_grpc_setup.py index 4e599674..30b501ca 100644 --- a/tests/services/setup/test_grpc_setup.py +++ b/tests/services/setup/test_grpc_setup.py @@ -10,23 +10,30 @@ import grpc_testing import pytest from agentic_mesh_protocol.setup.v1 import ( - setup_pb2, + setup_dto_pb2, setup_service_pb2, setup_service_pb2_grpc, + setup_messages_pb2 ) +from agentic_mesh_protocol.setup.v1.setup_messages_pb2 import SetupVersion from freezegun import freeze_time -from mock_setup_servicer import MockSetupServicer -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext +from digitalkin.exception.setup import SetupServiceError from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode -from digitalkin.services.setup.grpc_setup import GrpcSetup -from digitalkin.services.setup.setup_strategy import SetupData, SetupVersionData +from digitalkin.models.services.setup import SetupVersionData, SetupData +from digitalkin.services.setup.setup_grpc import GrpcSetup +from tests.fixtures.grpc_fixtures import FakeContext, AsyncStubWrapper +from tests.services.setup.mock_setup_servicer import MockSetupServicer service_instance = MockSetupServicer() service_name = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] alphabet = string.ascii_letters + string.digits +# --- Test Constants --- +MISSION_ID = "missions:test_mission" +SETUP_ID = "setups:test_setup" +SETUP_VERSION_ID = "setup_versions:test_version" @pytest.fixture def thread_pool(): @@ -78,7 +85,7 @@ def client(test_channel: grpc_testing.Channel) -> GrpcSetup: security=SecurityMode.INSECURE, credentials=None, ) - client = GrpcSetup() + client = GrpcSetup(MISSION_ID, SETUP_ID, SETUP_VERSION_ID, dummy_config) # emulate real instance client.__post_init__(dummy_config) @@ -100,7 +107,7 @@ def generate_setup_version_obj() -> SetupVersionData: setup_id=setup_id, version="v" + random_string(8), content={random_string(8): random_string(8) for _ in range(5)}, - creation_date=datetime.datetime.now(), # noqa: DTZ005 + created_at=datetime.datetime.now(), # noqa: DTZ005 ) @@ -110,7 +117,7 @@ def generate_setup_obj(generate_setup_version_obj: SetupVersionData) -> SetupDat return SetupData( id=generate_setup_version_obj.setup_id, name=random_string(), - organisation_id=random_string(), + organization_id=random_string(), owner_id=random_string(), module_id=random_string(), current_setup_version=generate_setup_version_obj, @@ -144,7 +151,7 @@ def test_create_setup_request_creation_success( grpc_test_server: Mock gRPC server for testing. """ # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) + future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump())) # Get the service and method descriptor. service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] @@ -157,7 +164,7 @@ def test_create_setup_request_creation_success( rpc.send_initial_metadata(()) rpc.terminate( # use the servicer to emulate a real request handling from a server - setup_pb2.CreateSetupResponse(success=True), + setup_dto_pb2.CreateSetupResponse(result=setup_messages_pb2.SetupResult(success=True)), (), grpc.StatusCode.OK, "", @@ -165,17 +172,17 @@ def test_create_setup_request_creation_success( # Verify that the client call returns success. result = future.result() - assert result.success is True + assert result.result.success is True # Verify the request correspond to the setup data assert request.name == generate_setup_obj.name - assert request.organisation_id == generate_setup_obj.organisation_id + assert request.organization_id == generate_setup_obj.organization_id assert request.owner_id == generate_setup_obj.owner_id assert request.current_setup_version.setup_id == generate_setup_obj.current_setup_version.setup_id assert request.current_setup_version.version == generate_setup_obj.current_setup_version.version assert ( - request.current_setup_version.creation_date.ToDatetime() - == generate_setup_obj.current_setup_version.creation_date + request.current_setup_version.created_at.ToDatetime() + == generate_setup_obj.current_setup_version.created_at ) assert dict(request.current_setup_version.content) == generate_setup_obj.current_setup_version.content @@ -200,7 +207,7 @@ def test_create_setup_success( grpc_test_server: Mock gRPC server for testing. """ # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) + future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump())) # Get the service and method descriptor. service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] @@ -211,8 +218,8 @@ def test_create_setup_success( # Use grpc_testing to send the response back to the client. rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupRequest(**{ - k: v for (k, v) in generate_setup_obj.model_dump().items() if k not in ("id") + request_obj = setup_dto_pb2.CreateSetupRequest(**{ + k: v for (k, v) in generate_setup_obj.model_dump().items() if k not in "id" }) rpc.terminate( @@ -225,7 +232,7 @@ def test_create_setup_success( # Verify that the client call returns success. result = future.result() - assert result.success is True + assert result.result.success is True setup = next( filter( @@ -236,11 +243,11 @@ def test_create_setup_success( assert isinstance(setup, SetupData) assert setup.name == generate_setup_obj.name - assert setup.organisation_id == generate_setup_obj.organisation_id + assert setup.organization_id == generate_setup_obj.organization_id assert setup.owner_id == generate_setup_obj.owner_id assert setup.current_setup_version.setup_id == generate_setup_obj.current_setup_version.setup_id assert setup.current_setup_version.version == generate_setup_obj.current_setup_version.version - assert setup.current_setup_version.creation_date == generate_setup_obj.current_setup_version.creation_date + assert setup.current_setup_version.created_at == generate_setup_obj.current_setup_version.created_at assert setup.current_setup_version.content == generate_setup_obj.current_setup_version.content # Test RegisterModule @@ -269,8 +276,8 @@ def test_create_setup_validation_error( generate_setup_obj.current_setup_version = None # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump(warnings=False))) - with pytest.raises(ValueError, match="Validation failed for Setup Creation"): + future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump(warnings=False))) + with pytest.raises(SetupServiceError, match="Unexpected error in CreateSetup"): future.result() @@ -302,10 +309,10 @@ def test_get_setup_success( get_method_desc = service_desc.methods_by_name["GetSetup"] # First create a setup - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump())) _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupRequest(**{ + request_obj = setup_dto_pb2.CreateSetupRequest(**{ k: v for (k, v) in generate_setup_obj.model_dump().items() if k != "id" }) create_response = mock_servicer.CreateSetup(request_obj, FakeContext()) @@ -316,7 +323,7 @@ def test_get_setup_success( created_setup_id = next(iter(mock_servicer.setups.keys())) # Now get the setup - get_future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": created_setup_id})) + get_future = thread_pool.submit(asyncio.run, client.get({"setup_id": created_setup_id})) _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) assert get_request.setup_id == created_setup_id @@ -329,7 +336,7 @@ def test_get_setup_success( result = get_future.result() assert result is not None assert result.name == generate_setup_obj.name - assert result.organisation_id == generate_setup_obj.organisation_id + assert result.organization_id == generate_setup_obj.organization_id assert result.owner_id == generate_setup_obj.owner_id @pytest.mark.grpc @@ -349,7 +356,7 @@ def test_get_setup_not_found( service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] get_method_desc = service_desc.methods_by_name["GetSetup"] - get_future = thread_pool.submit(asyncio.run, client.get_setup({"setup_id": "nonexistent_id"})) + get_future = thread_pool.submit(asyncio.run, client.get({"setup_id": "nonexistent_id"})) _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) get_context = FakeContext() @@ -383,22 +390,22 @@ def test_update_setup_servicer_direct( avoiding grpc_testing framework issues. """ # First create a setup in the servicer - create_request = setup_pb2.CreateSetupRequest( + create_request = setup_dto_pb2.CreateSetupRequest( name=generate_setup_obj.name, - organisation_id=generate_setup_obj.organisation_id, + organization_id=generate_setup_obj.organization_id, owner_id=generate_setup_obj.owner_id, module_id=generate_setup_obj.module_id, - current_setup_version=setup_pb2.SetupVersion(**generate_setup_obj.current_setup_version.model_dump()), + current_setup_version=SetupVersion(**generate_setup_obj.current_setup_version.model_dump()), ) create_context = FakeContext() create_response = mock_servicer.CreateSetup(create_request, create_context) - assert create_response.success is True + assert create_response.result.success is True # Get the created setup's ID created_setup_id = next(iter(mock_servicer.setups.keys())) # Now test UpdateSetup servicer method directly - update_request = setup_pb2.UpdateSetupRequest( + update_request = setup_dto_pb2.UpdateSetupRequest( setup_id=created_setup_id, name="Updated Name", owner_id="new_owner_id", @@ -408,7 +415,7 @@ def test_update_setup_servicer_direct( update_response = mock_servicer.UpdateSetup(update_request, update_context) # Verify the update succeeded - assert update_response.success is True + assert update_response.result.setup is not None assert update_context._code == grpc.StatusCode.OK # Verify the data was actually updated @@ -439,7 +446,7 @@ def test_update_setup_success( test_setup = SetupData( id=setup_id, name="Original Name", - organisation_id=generate_setup_obj.organisation_id, + organization_id=generate_setup_obj.organization_id, owner_id="original_owner_id", module_id=generate_setup_obj.module_id, current_setup_version=generate_setup_obj.current_setup_version, @@ -452,12 +459,12 @@ def test_update_setup_success( "name": "Updated Name", "owner_id": "new_owner_id", "module_id": generate_setup_obj.module_id, - "organisation_id": generate_setup_obj.organisation_id, + "organization_id": generate_setup_obj.organization_id, "current_setup_version": generate_setup_obj.current_setup_version, } # Start the update call - update_future = thread_pool.submit(asyncio.run, client.update_setup(updated_data)) + update_future = thread_pool.submit(asyncio.run, client.update(updated_data)) # Intercept the call update_method_desc = service_desc.methods_by_name["UpdateSetup"] @@ -506,7 +513,7 @@ def test_update_setup_not_found( updated_data = generate_setup_obj.model_dump() updated_data["id"] = "nonexistent_id" - update_future = thread_pool.submit(asyncio.run, client.update_setup(updated_data)) + update_future = thread_pool.submit(asyncio.run, client.update(updated_data)) _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) update_context = FakeContext() @@ -546,10 +553,10 @@ def test_delete_setup_success( delete_method_desc = service_desc.methods_by_name["DeleteSetup"] # First create a setup - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump())) _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupRequest(**{ + request_obj = setup_dto_pb2.CreateSetupRequest(**{ k: v for (k, v) in generate_setup_obj.model_dump().items() if k != "id" }) create_response = mock_servicer.CreateSetup(request_obj, FakeContext()) @@ -560,7 +567,7 @@ def test_delete_setup_success( created_setup_id = next(iter(mock_servicer.setups.keys())) # Delete the setup - delete_future = thread_pool.submit(asyncio.run, client.delete_setup({"setup_id": created_setup_id})) + delete_future = thread_pool.submit(asyncio.run, client.delete({"setup_id": created_setup_id})) _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) assert delete_request.setup_id == created_setup_id @@ -593,7 +600,7 @@ def test_delete_setup_not_found( service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] delete_method_desc = service_desc.methods_by_name["DeleteSetup"] - delete_future = thread_pool.submit(asyncio.run, client.delete_setup({"setup_id": "nonexistent_id"})) + delete_future = thread_pool.submit(asyncio.run, client.delete({"setup_id": "nonexistent_id"})) _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) delete_context = FakeContext() @@ -605,488 +612,6 @@ def test_delete_setup_not_found( result = delete_future.result() assert result is False - -class TestSetupVersionOperations: - """Tests for setup version CRUD operations. - - Verifies creation, retrieval, search, update, and deletion of setup versions, - including error handling for non-existent versions. - """ - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_create_setup_version_request_creation_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successful create_setup_version with a good request. - - Verifies that create_setup create the good request. - - Args: - grpc_test_server: Mock gRPC server for testing. - """ - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - - # Get the service and method descriptor. - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - method_desc = service_desc.methods_by_name["CreateSetupVersion"] - - # Intercept the pending unary-unary call. - _, request, rpc = test_channel.take_unary_unary(method_desc) - - # Use grpc_testing to send the response back to the client. - rpc.send_initial_metadata(()) - rpc.terminate( - # use the servicer to emulate a real request handling from a server - setup_pb2.CreateSetupVersionResponse(success=True), - (), - grpc.StatusCode.OK, - "", - ) - - # Verify that the client call returns success. - result = future.result() - assert result.success is True - - # Verify the request correspond to the setup data - assert request.setup_id == generate_setup_version_obj.setup_id - assert request.version == generate_setup_version_obj.version - assert dict(request.content) == generate_setup_version_obj.content - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_create_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successful create_setup_version. - - Verifies that create_setup_version RPC call with a valid request using the fake servicer. - - Args: - grpc_test_server: Mock gRPC server for testing. - """ - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - - # Get the service and method descriptor. - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - method_desc = service_desc.methods_by_name["CreateSetupVersion"] - - # Intercept the pending unary-unary call. - _, _request, rpc = test_channel.take_unary_unary(method_desc) - - # Use grpc_testing to send the response back to the client. - rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - - rpc.terminate( - # use the servicer to emulate a real request handling from a server - mock_servicer.CreateSetupVersion(request_obj, FakeContext()), - (), - grpc.StatusCode.OK, - "", - ) - - # Verify that the client call returns success. - result = future.result() - assert result.success is True - - setup_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - assert isinstance(setup_version, SetupVersionData) - # Verify the request correspond to the setup data - assert setup_version.setup_id == generate_setup_version_obj.setup_id - assert setup_version.version == generate_setup_version_obj.version - assert setup_version.creation_date == generate_setup_version_obj.creation_date - assert setup_version.content == generate_setup_version_obj.content - - # Test RegisterModule - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_create_setup_version_validation_error( - self, - client: GrpcSetup, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test registration of a duplicate module. - - Verifies that attempting to register a module with an ID that already exists - results in an error response with ALREADY_EXISTS status code. - - Args: - grpc_test_server: Mock gRPC server for testing. - module_registry_obj: Pre-registered module fixture for testing duplicates. - """ - # Try to register a module with an ID that already exists - # Convert the module object to a request, excluding status and message fields - generate_setup_version_obj.creation_date = [] - generate_setup_version_obj.content = "" - - # Start the client call (this call will block until the response is simulated). - future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump(warnings=False))) - with pytest.raises(ValueError, match="Validation failed for Setup Version Creation"): - future.result() - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_get_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully retrieving a setup version. - - Verifies that get_setup_version returns the correct setup version data. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - get_method_desc = service_desc.methods_by_name["GetSetupVersion"] - - # First create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created version's ID (it's stored as version key in mock servicer) - created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - # Now get the setup version by ID - get_future = thread_pool.submit(asyncio.run, client.get_setup_version({"setup_version_id": created_version.id})) - _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) - - assert get_request.setup_version_id == created_version.id - - get_context = FakeContext() - get_response = mock_servicer.GetSetupVersion(get_request, get_context) - get_rpc.send_initial_metadata(()) - get_rpc.terminate(get_response, (), grpc.StatusCode.OK, "") - - result = get_future.result() - assert result is not None - assert result.setup_id == generate_setup_version_obj.setup_id - assert result.version == generate_setup_version_obj.version - assert result.content == generate_setup_version_obj.content - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_get_setup_version_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test getting a non-existent setup version raises error. - - Verifies that attempting to get a non-existent setup version results in error. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - get_method_desc = service_desc.methods_by_name["GetSetupVersion"] - - get_future = thread_pool.submit(asyncio.run, client.get_setup_version({"setup_version_id": "nonexistent_version_id"})) - _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) - - get_context = FakeContext() - get_response = mock_servicer.GetSetupVersion(get_request, get_context) - get_rpc.send_initial_metadata(()) - get_rpc.terminate(get_response, (), get_context._code, get_context._details) - - with pytest.raises(Exception): - get_future.result() - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_search_setup_versions_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully searching setup versions. - - Verifies that search_setup_versions returns matching versions. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - search_method_desc = service_desc.methods_by_name["SearchSetupVersions"] - - # Create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Search for versions - search_future = thread_pool.submit( - asyncio.run, - client.search_setup_versions( - {"setup_id": generate_setup_version_obj.setup_id, "version": generate_setup_version_obj.version} - ), - ) - _, search_request, search_rpc = test_channel.take_unary_unary(search_method_desc) - - assert search_request.setup_id == generate_setup_version_obj.setup_id - assert search_request.version == generate_setup_version_obj.version - - search_context = FakeContext() - search_response = mock_servicer.SearchSetupVersions(search_request, search_context) - search_rpc.send_initial_metadata(()) - search_rpc.terminate(search_response, (), grpc.StatusCode.OK, "") - - result = search_future.result() - assert len(result) == 1 - assert result[0].setup_id == generate_setup_version_obj.setup_id - assert result[0].version == generate_setup_version_obj.version - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.edge_case - def test_search_setup_versions_empty_results( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test searching for setup versions with no results. - - Verifies that search_setup_versions returns empty list when no matches found. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - search_method_desc = service_desc.methods_by_name["SearchSetupVersions"] - - search_future = thread_pool.submit( - asyncio.run, client.search_setup_versions({"setup_id": "nonexistent_setup", "version": "v1.0.0"}) - ) - _, search_request, search_rpc = test_channel.take_unary_unary(search_method_desc) - - search_context = FakeContext() - search_response = mock_servicer.SearchSetupVersions(search_request, search_context) - search_rpc.send_initial_metadata(()) - search_rpc.terminate(search_response, (), search_context._code, search_context._details) - - with pytest.raises(Exception): - search_future.result() - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_update_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully updating a setup version. - - Verifies that update_setup_version updates the version data correctly. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - update_method_desc = service_desc.methods_by_name["UpdateSetupVersion"] - - # First create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created version - created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - # Update the setup version - updated_data = generate_setup_version_obj.model_dump() - updated_data["id"] = created_version.id - updated_data["content"] = {"updated_key": "updated_value"} - - update_future = thread_pool.submit(asyncio.run, client.update_setup_version(updated_data)) - _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) - - assert update_request.setup_version_id == created_version.id - - update_context = FakeContext() - update_response = mock_servicer.UpdateSetupVersion(update_request, update_context) - update_rpc.send_initial_metadata(()) - update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") - - result = update_future.result() - assert result is True - - # Verify the update in mock servicer - updated_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - assert updated_version.content == {"updated_key": "updated_value"} - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_update_setup_version_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test updating a non-existent setup version returns False. - - Verifies that attempting to update a non-existent setup version returns False. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - update_method_desc = service_desc.methods_by_name["UpdateSetupVersion"] - - updated_data = generate_setup_version_obj.model_dump() - updated_data["id"] = "nonexistent_version_id" - - update_future = thread_pool.submit(asyncio.run, client.update_setup_version(updated_data)) - _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) - - update_context = FakeContext() - update_response = mock_servicer.UpdateSetupVersion(update_request, update_context) - update_rpc.send_initial_metadata(()) - # When setup version doesn't exist, return OK status with success=False - update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") - - result = update_future.result() - assert result is False - - @freeze_time("2025-04-01 12:00:01") - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.smoke - def test_delete_setup_version_success( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - generate_setup_version_obj: SetupVersionData, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test successfully deleting a setup version. - - Verifies that delete_setup_version removes the version from storage. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] - delete_method_desc = service_desc.methods_by_name["DeleteSetupVersion"] - - # First create a setup version - create_future = thread_pool.submit(asyncio.run, client.create_setup_version(generate_setup_version_obj.model_dump())) - _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) - create_rpc.send_initial_metadata(()) - request_obj = setup_pb2.CreateSetupVersionRequest(**{ - k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"creation_date", "id"} - }) - create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) - create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") - create_future.result() - - # Get the created version - created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ - generate_setup_version_obj.version - ] - - # Delete the setup version - delete_future = thread_pool.submit(asyncio.run, client.delete_setup_version({"setup_version_id": created_version.id})) - _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) - - assert delete_request.setup_version_id == created_version.id - - delete_context = FakeContext() - delete_response = mock_servicer.DeleteSetupVersion(delete_request, delete_context) - delete_rpc.send_initial_metadata(()) - delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") - - result = delete_future.result() - assert result is True - - # Verify deletion in mock servicer - assert generate_setup_version_obj.setup_id not in mock_servicer.setup_versions - - @pytest.mark.grpc - @pytest.mark.integration - @pytest.mark.validation - def test_delete_setup_version_not_found( - self, - client: GrpcSetup, - test_channel: grpc_testing.Channel, - mock_servicer: MockSetupServicer, - thread_pool: futures.ThreadPoolExecutor, - ) -> None: - """Test deleting a non-existent setup version returns False. - - Verifies that attempting to delete a non-existent setup version returns False. - """ - service_desc = setup_service_pb2.DESCRIPTOR.services_by_name["SetupService"] - delete_method_desc = service_desc.methods_by_name["DeleteSetupVersion"] - - delete_future = thread_pool.submit(asyncio.run, client.delete_setup_version({"setup_version_id": "nonexistent_version_id"})) - _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) - - delete_context = FakeContext() - delete_response = mock_servicer.DeleteSetupVersion(delete_request, delete_context) - delete_rpc.send_initial_metadata(()) - # When setup version doesn't exist, return OK status with success=False - delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") - - result = delete_future.result() - assert result is False - - class TestListSetups: """Tests for list_setups() method. @@ -1097,12 +622,12 @@ class TestListSetups: @pytest.mark.integration @pytest.mark.smoke def test_list_setups_success( - self, - client, - test_channel, - thread_pool, - mock_servicer, - generate_setup_obj, + self, + client, + test_channel, + thread_pool, + mock_servicer, + generate_setup_obj, ) -> None: """Test successfully listing all setups. @@ -1113,7 +638,7 @@ def test_list_setups_success( # Create three setups for i in range(3): - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump())) _, create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) create_context = FakeContext() create_response = mock_servicer.CreateSetup(create_request, create_context) @@ -1123,7 +648,7 @@ def test_list_setups_success( # List all setups list_method_desc = service_desc.methods_by_name["ListSetups"] - list_future = thread_pool.submit(asyncio.run, client.list_setups({})) + list_future = thread_pool.submit(asyncio.run, client.list({})) _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) list_context = FakeContext() @@ -1139,12 +664,12 @@ def test_list_setups_success( @pytest.mark.integration @pytest.mark.smoke def test_list_setups_with_pagination( - self, - client, - test_channel, - thread_pool, - mock_servicer, - generate_setup_obj, + self, + client, + test_channel, + thread_pool, + mock_servicer, + generate_setup_obj, ) -> None: """Test listing setups with pagination. @@ -1155,7 +680,7 @@ def test_list_setups_with_pagination( # Create 5 setups for i in range(5): - create_future = thread_pool.submit(asyncio.run, client.create_setup(generate_setup_obj.model_dump())) + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_obj.model_dump())) _, create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) create_context = FakeContext() create_response = mock_servicer.CreateSetup(create_request, create_context) @@ -1165,7 +690,7 @@ def test_list_setups_with_pagination( # List first 2 setups list_method_desc = service_desc.methods_by_name["ListSetups"] - list_future = thread_pool.submit(asyncio.run, client.list_setups({"limit": 2, "offset": 0})) + list_future = thread_pool.submit(asyncio.run, client.list({"limit": 2, "offset": 0})) _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) list_context = FakeContext() @@ -1178,7 +703,7 @@ def test_list_setups_with_pagination( assert len(result["setups"]) == 2 # List next 2 setups (offset 2) - list_future2 = thread_pool.submit(asyncio.run, client.list_setups({"limit": 2, "offset": 2})) + list_future2 = thread_pool.submit(asyncio.run, client.list({"limit": 2, "offset": 2})) _, list_request2, list_rpc2 = test_channel.take_unary_unary(list_method_desc) list_context2 = FakeContext() @@ -1194,11 +719,11 @@ def test_list_setups_with_pagination( @pytest.mark.integration @pytest.mark.edge_case def test_list_setups_empty( - self, - client, - test_channel, - thread_pool, - mock_servicer, + self, + client, + test_channel, + thread_pool, + mock_servicer, ) -> None: """Test listing setups when no setups exist. @@ -1208,7 +733,7 @@ def test_list_setups_empty( list_method_desc = service_desc.methods_by_name["ListSetups"] # List setups (empty database) - list_future = thread_pool.submit(asyncio.run, client.list_setups({})) + list_future = thread_pool.submit(asyncio.run, client.list({})) _, list_request, list_rpc = test_channel.take_unary_unary(list_method_desc) list_context = FakeContext() @@ -1220,7 +745,6 @@ def test_list_setups_empty( assert result["total_count"] == 0 assert len(result["setups"]) == 0 - # ============================================================================ # Regression Tests # ============================================================================ diff --git a/tests/services/setup/version/__init__.py b/tests/services/setup/version/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/setup/version/mock_setup_version_servicer.py b/tests/services/setup/version/mock_setup_version_servicer.py new file mode 100644 index 00000000..8159fc18 --- /dev/null +++ b/tests/services/setup/version/mock_setup_version_servicer.py @@ -0,0 +1,170 @@ +"""Test file for Module setup Servicer from the client side.""" + +import datetime +import secrets +import string + +import grpc +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 +from agentic_mesh_protocol.setup.v1 import ( + setup_version_service_pb2_grpc, + setup_version_dto_pb2, + setup_messages_pb2, +) +from google.protobuf import json_format +from pydantic import ValidationError + +from digitalkin.logger import logger +from digitalkin.models.services.setup import SetupData, SetupVersionData + + +class MockSetupVersionServicer(setup_version_service_pb2_grpc.SetupVersionServiceServicer): + """Implementation of the MockSetupServicer.""" + + alphabet = string.ascii_letters + string.digits + + setups: dict[str, SetupData] + setup_versions: dict[str, dict[str, SetupVersionData]] + + def _generate_id(self) -> str: + return "".join(secrets.choice(self.alphabet) for _ in range(16)) + + def __init__(self) -> None: + """Initialize the setup servicer with an empty setups.""" + super().__init__() + self.setups = {} + self.setup_versions = {} + + def CreateSetupVersion( + self, request: setup_version_dto_pb2.CreateSetupVersionRequest, context: grpc.ServicerContext + ) -> setup_version_dto_pb2.CreateSetupVersionResponse: + try: + setup_data_version = SetupVersionData( + id=self._generate_id(), + setup_id=request.setup_id, + version=request.version, + created_at=datetime.datetime.now(), # noqa: DTZ005 + content=dict(request.content), + ) + except ValidationError: + msg = "Validation failed for model SetupVersionData" + logger.warning(msg) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(msg) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT), + message=msg)) + return setup_version_dto_pb2.CreateSetupVersionResponse(result=result) + + if request.setup_id not in self.setup_versions: + self.setup_versions[request.setup_id] = {} + self.setup_versions[request.setup_id][setup_data_version.version] = setup_data_version + logger.debug("CREATE SETUP VERSION DATA %s:%s succesfull", request.setup_id, setup_data_version) + result = setup_messages_pb2.SetupResult(version=setup_messages_pb2.SetupVersion(**setup_data_version.model_dump()), + success=True) + return setup_version_dto_pb2.CreateSetupVersionResponse(result=result) + + def GetSetupVersion( + self, request: setup_version_dto_pb2.GetSetupVersionRequest, context: grpc.ServicerContext + ) -> setup_version_dto_pb2.GetSetupVersionResponse: + logger.debug("GET SETUP VERSION setup_version_id = %s.", request.setup_version_id) + + # Search for the setup version with the matching ID + setup_version = None + for setup_versions in self.setup_versions.values(): + for version_data in setup_versions.values(): + if version_data.id == request.setup_version_id: + setup_version = version_data + break + if setup_version: + break + + if setup_version is None: + msg = f"GET SETUP VERSION setup_version_id = {request.setup_version_id} | name DOESN'T EXIST" + logger.warning(msg) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(msg) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), + message=msg)) + return setup_version_dto_pb2.GetSetupVersionResponse(result=result) + result = setup_messages_pb2.SetupResult(version=setup_messages_pb2.SetupVersion(**setup_version.model_dump()), + success=True) + return setup_version_dto_pb2.GetSetupVersionResponse(result=result) + + def SearchSetupVersions( + self, request: setup_version_dto_pb2.SearchSetupVersionsRequest, context: grpc.ServicerContext + ) -> setup_version_dto_pb2.SearchSetupVersionsResponse: + if request.setup_id is None or request.setup_id not in self.setup_versions: + msg = f"GET setup_id = {request.setup_id}: setup_id DOESN'T EXIST" + logger.warning(msg) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(msg) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), + message=msg)) + return setup_version_dto_pb2.SearchSetupVersionsResponse(result=[result]) + + query_setup_versions = self.setup_versions[request.setup_id] + if request.version: + query_setup_versions = {k: v for k, v in query_setup_versions.items() if request.version in k} + setup_versions = [setup_messages_pb2.SetupVersion(**value.model_dump()) for value in query_setup_versions.values()] + result = [setup_messages_pb2.SetupResult(version=version, success=True) for version in setup_versions] + return setup_version_dto_pb2.SearchSetupVersionsResponse(result=result) + + def UpdateSetupVersion( + self, request: setup_version_dto_pb2.UpdateSetupVersionRequest, context: grpc.ServicerContext + ) -> setup_version_dto_pb2.UpdateSetupVersionResponse: + # Search for the setup version with the matching ID + setup_version = None + for setup_versions in self.setup_versions.values(): + for version_data in setup_versions.values(): + if version_data.id == request.setup_version_id: + setup_version = version_data + break + if setup_version: + break + + if setup_version is None: + msg = "UPDATE setup_version_id = {request.setup_version_id}: setup_version_id DOESN'T EXIST" + logger.warning(msg) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(msg) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), + message=msg)) + return setup_version_dto_pb2.UpdateSetupVersionResponse(result=result) + + self.setup_versions[setup_version.setup_id][setup_version.version].content = json_format.MessageToDict( + request.content + ) + version = setup_messages_pb2.SetupVersion(**setup_version.model_dump()) + result = setup_messages_pb2.SetupResult(version=version, success=True) + return setup_version_dto_pb2.UpdateSetupVersionResponse(result=result) + + def DeleteSetupVersion( + self, request: setup_version_dto_pb2.DeleteSetupVersionRequest, context: grpc.ServicerContext + ) -> setup_version_dto_pb2.DeleteSetupVersionResponse: + # Search for the setup version with the matching ID + setup_version = None + for setup_versions in self.setup_versions.values(): + for version_data in setup_versions.values(): + if version_data.id == request.setup_version_id: + setup_version = version_data + break + if setup_version: + break + + if setup_version is None: + msg = f"DELETE name = {request.setup_version_id} | name DOESN'T EXIST" + logger.warning(msg) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(msg) + result = setup_messages_pb2.SetupResult(success=False, error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND), + message=msg)) + return setup_version_dto_pb2.DeleteSetupVersionResponse(result=result) + + # Delete only the specific version, not all versions for this setup + version = setup_messages_pb2.SetupVersion(**setup_version.model_dump()) + del self.setup_versions[setup_version.setup_id][setup_version.version] + # If this was the last version for this setup, remove the setup entry as well + if not self.setup_versions[setup_version.setup_id]: + del self.setup_versions[setup_version.setup_id] + result = setup_messages_pb2.SetupResult(version=version, success=True) + return setup_version_dto_pb2.DeleteSetupVersionResponse(result=result) diff --git a/tests/services/setup/version/test_grpc_setup_version.py b/tests/services/setup/version/test_grpc_setup_version.py new file mode 100644 index 00000000..5ac3d798 --- /dev/null +++ b/tests/services/setup/version/test_grpc_setup_version.py @@ -0,0 +1,656 @@ +"""Test the grpc service.""" +import asyncio +import datetime +import secrets +import string +from concurrent import futures + +import grpc +import grpc_testing +import pytest +from agentic_mesh_protocol.setup.v1 import ( + setup_version_service_pb2_grpc, + setup_version_service_pb2, + setup_version_dto_pb2, + setup_messages_pb2 +) +from freezegun import freeze_time + +from digitalkin.exception.setup import SetupVersionServiceError +from digitalkin.models.grpc_servers.models import ClientConfig, SecurityMode, ServerMode +from digitalkin.models.services.setup import SetupData, SetupVersionData +from digitalkin.services.setup.setup_grpc import GrpcSetup +from digitalkin.services.setup.version.setup_version_grpc import GrpcSetupVersion +from tests.fixtures.grpc_fixtures import AsyncStubWrapper +from tests.fixtures.grpc_fixtures import FakeContext +from tests.services.setup.version.mock_setup_version_servicer import MockSetupVersionServicer + +service_instance = MockSetupVersionServicer() +service_name = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + +alphabet = string.ascii_letters + string.digits + +# --- Test Constants --- +MISSION_ID = "missions:test_mission" +SETUP_ID = "setups:test_setup_version" +SETUP_VERSION_ID = "setup_versions:test_version" + + +@pytest.fixture +def thread_pool(): + """Create thread pool and ensure cleanup. + + Returns: + ThreadPoolExecutor instance + """ + pool = futures.ThreadPoolExecutor(max_workers=1) + yield pool + pool.shutdown(wait=True, cancel_futures=True) + + +@pytest.fixture +def test_channel() -> grpc_testing.Channel: + """Mock a gRPC channel. + + Returns: + Mock gRPC Channel + """ + # Create a strict real time test clock + test_clock = grpc_testing.strict_real_time() + # Create a test channel with our service descriptor and our fake servicer + return grpc_testing.channel([service_name], test_clock) + + +@pytest.fixture +def mock_servicer() -> MockSetupVersionServicer: + """Return an instance of the mock servicer. + + Returns: + Mock Setup Servicer + """ + return MockSetupVersionServicer() + + +@pytest.fixture +def client(test_channel: grpc_testing.Channel) -> GrpcSetup: + """Instantiate a GrpcSetupService client that uses the test channel. + + Returns: + gRPC client as GrpcSetup + """ + # Create a dummy ServerConfig; its values are not used since we override _init_channel. + dummy_config = ClientConfig( + host="[::]", + port=50151, + mode=ServerMode.ASYNC, + security=SecurityMode.INSECURE, + credentials=None, + ) + client = GrpcSetupVersion(MISSION_ID, SETUP_ID, SETUP_VERSION_ID, dummy_config) + # emulate real instance + client.__post_init__(dummy_config) + + # Override the channel and stub to use our test channel + client.stub = AsyncStubWrapper(setup_version_service_pb2_grpc.SetupVersionServiceStub(test_channel)) + return client + + +def random_string(number: int = 16) -> str: + return "".join(secrets.choice(alphabet) for _ in range(number)) + + +@pytest.fixture +@freeze_time("2025-04-01 12:00:01") +def generate_setup_version_obj() -> SetupVersionData: + setup_id = random_string() + return SetupVersionData( + id=random_string(), + setup_id=setup_id, + version="v" + random_string(8), + content={random_string(8): random_string(8) for _ in range(5)}, + created_at=datetime.datetime.now(), # noqa: DTZ005 + ) + + +@pytest.fixture +def generate_setup_obj(generate_setup_version_obj: SetupVersionData) -> SetupData: + # Create registration request with test setup data + return SetupData( + id=generate_setup_version_obj.setup_id, + name=random_string(), + organization_id=random_string(), + owner_id=random_string(), + module_id=random_string(), + current_setup_version=generate_setup_version_obj, + ) + + +class TestCreateSetupVersion: + """Tests for create_setup_version() method. + + Verifies successful setup version creation, request validation, and error handling + for invalid data. + """ + + @freeze_time("2025-04-01 12:00:01") + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_create_setup_version_request_creation_success( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successful create_setup_version with a good request. + + Verifies that create_setup create the good request. + + Args: + grpc_test_server: Mock gRPC server for testing. + """ + # Start the client call (this call will block until the response is simulated). + future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump())) + + # Get the service and method descriptor. + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + method_desc = service_desc.methods_by_name["CreateSetupVersion"] + + # Intercept the pending unary-unary call. + _, request, rpc = test_channel.take_unary_unary(method_desc) + + # Use grpc_testing to send the response back to the client. + rpc.send_initial_metadata(()) + rpc.terminate( + # use the servicer to emulate a real request handling from a server + setup_version_dto_pb2.CreateSetupVersionResponse(result=setup_messages_pb2.SetupResult(success=True)), + (), + grpc.StatusCode.OK, + "", + ) + + # Verify that the client call returns success. + result = future.result() + assert result.result.success is True + + # Verify the request correspond to the setup data + assert request.setup_id == generate_setup_version_obj.setup_id + assert request.version == generate_setup_version_obj.version + assert dict(request.content) == generate_setup_version_obj.content + + @freeze_time("2025-04-01 12:00:01") + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_create_setup_version_success( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successful create_setup_version. + + Verifies that create_setup_version RPC call with a valid request using the fake servicer. + + Args: + grpc_test_server: Mock gRPC server for testing. + """ + # Start the client call (this call will block until the response is simulated). + future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump())) + + # Get the service and method descriptor. + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + method_desc = service_desc.methods_by_name["CreateSetupVersion"] + + # Intercept the pending unary-unary call. + _, _request, rpc = test_channel.take_unary_unary(method_desc) + + # Use grpc_testing to send the response back to the client. + rpc.send_initial_metadata(()) + request_obj = setup_version_dto_pb2.CreateSetupVersionRequest(**{ + k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"created_at", "id"} + }) + + rpc.terminate( + # use the servicer to emulate a real request handling from a server + mock_servicer.CreateSetupVersion(request_obj, FakeContext()), + (), + grpc.StatusCode.OK, + "", + ) + + # Verify that the client call returns success. + result = future.result() + assert result.result.success is True + + setup_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ + generate_setup_version_obj.version + ] + + assert isinstance(setup_version, SetupVersionData) + # Verify the request correspond to the setup data + assert setup_version.setup_id == generate_setup_version_obj.setup_id + assert setup_version.version == generate_setup_version_obj.version + assert setup_version.created_at == generate_setup_version_obj.created_at + assert setup_version.content == generate_setup_version_obj.content + + # Test RegisterModule + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.validation + def test_create_setup_version_validation_error( + self, + client: GrpcSetupVersion, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test registration of a duplicate module. + + Verifies that attempting to register a module with an ID that already exists + results in an error response with ALREADY_EXISTS status code. + + Args: + grpc_test_server: Mock gRPC server for testing. + module_registry_obj: Pre-registered module fixture for testing duplicates. + """ + # Try to register a module with an ID that already exists + # Convert the module object to a request, excluding status and message fields + generate_setup_version_obj.created_at = [] + generate_setup_version_obj.content = "" + + # Start the client call (this call will block until the response is simulated). + future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump(warnings=False))) + with pytest.raises(SetupVersionServiceError, match="Unexpected error in Setup Version Creation"): + future.result() + + +class TestGetSetupVersion: + """Tests for get_setup_version() method. + + Verifies successful retrieval of setup version data and handling of non-existent versions. + """ + + @freeze_time("2025-04-01 12:00:01") + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_get_setup_version_success( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successfully retrieving a setup version. + + Verifies that get_setup_version returns the correct setup version data. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] + get_method_desc = service_desc.methods_by_name["GetSetupVersion"] + + # First create a setup version + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump())) + _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) + create_rpc.send_initial_metadata(()) + request_obj = setup_version_dto_pb2.CreateSetupVersionRequest(**{ + k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"created_at", "id"} + }) + create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) + create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") + create_future.result() + + # Get the created version's ID (it's stored as version key in mock servicer) + created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ + generate_setup_version_obj.version + ] + + # Now get the setup version by ID + get_future = thread_pool.submit(asyncio.run, client.get({"setup_version_id": created_version.id})) + _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) + + assert get_request.setup_version_id == created_version.id + + get_context = FakeContext() + get_response = mock_servicer.GetSetupVersion(get_request, get_context) + get_rpc.send_initial_metadata(()) + get_rpc.terminate(get_response, (), grpc.StatusCode.OK, "") + + result = get_future.result() + assert result is not None + assert result.setup_id == generate_setup_version_obj.setup_id + assert result.version == generate_setup_version_obj.version + assert result.content == generate_setup_version_obj.content + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.validation + def test_get_setup_version_not_found( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test getting a non-existent setup version raises error. + + Verifies that attempting to get a non-existent setup version results in error. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + get_method_desc = service_desc.methods_by_name["GetSetupVersion"] + + get_future = thread_pool.submit(asyncio.run, client.get({"setup_version_id": "nonexistent_version_id"})) + _, get_request, get_rpc = test_channel.take_unary_unary(get_method_desc) + + get_context = FakeContext() + get_response = mock_servicer.GetSetupVersion(get_request, get_context) + get_rpc.send_initial_metadata(()) + get_rpc.terminate(get_response, (), get_context._code, get_context._details) + + with pytest.raises(Exception): + get_future.result() + + +class TestSearchSetupVersions: + """Tests for search_setup_versions() method. + + Verifies successful search of setup versions, filtering capabilities, and handling + of empty results. + """ + + @freeze_time("2025-04-01 12:00:01") + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_search_setup_versions_success( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successfully searching setup versions. + + Verifies that search_setup_versions returns matching versions. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] + search_method_desc = service_desc.methods_by_name["SearchSetupVersions"] + + # Create a setup version + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump())) + _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) + create_rpc.send_initial_metadata(()) + request_obj = setup_version_dto_pb2.CreateSetupVersionRequest(**{ + k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"created_at", "id"} + }) + create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) + create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") + create_future.result() + + # Search for versions + search_future = thread_pool.submit( + asyncio.run, + client.search( + {"setup_id": generate_setup_version_obj.setup_id, "version": generate_setup_version_obj.version} + ), + ) + _, search_request, search_rpc = test_channel.take_unary_unary(search_method_desc) + + assert search_request.setup_id == generate_setup_version_obj.setup_id + assert search_request.version == generate_setup_version_obj.version + + search_context = FakeContext() + search_response = mock_servicer.SearchSetupVersions(search_request, search_context) + search_rpc.send_initial_metadata(()) + search_rpc.terminate(search_response, (), grpc.StatusCode.OK, "") + + result = search_future.result() + assert len(result) == 1 + assert result[0].setup_id == generate_setup_version_obj.setup_id + assert result[0].version == generate_setup_version_obj.version + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.edge_case + def test_search_setup_versions_empty_results( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test searching for setup versions with no results. + + Verifies that search_setup_versions returns empty list when no matches found. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + search_method_desc = service_desc.methods_by_name["SearchSetupVersions"] + + search_future = thread_pool.submit( + asyncio.run, client.search({"setup_id": "nonexistent_setup", "version": "v1.0.0"}) + ) + _, search_request, search_rpc = test_channel.take_unary_unary(search_method_desc) + + search_context = FakeContext() + search_response = mock_servicer.SearchSetupVersions(search_request, search_context) + search_rpc.send_initial_metadata(()) + search_rpc.terminate(search_response, (), search_context._code, search_context._details) + + with pytest.raises(Exception): + search_future.result() + + +class TestUpdateSetupVersion: + """Tests for update_setup_version() method. + + Verifies successful updates and handling of non-existent setup versions. + """ + + @freeze_time("2025-04-01 12:00:01") + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_update_setup_version_success( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successfully updating a setup version. + + Verifies that update_setup_version updates the version data correctly. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] + update_method_desc = service_desc.methods_by_name["UpdateSetupVersion"] + + # First create a setup version + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump())) + _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) + create_rpc.send_initial_metadata(()) + request_obj = setup_version_dto_pb2.CreateSetupVersionRequest(**{ + k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"created_at", "id"} + }) + create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) + create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") + create_future.result() + + # Get the created version + created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ + generate_setup_version_obj.version + ] + + # Update the setup version + updated_data = generate_setup_version_obj.model_dump() + updated_data["id"] = created_version.id + updated_data["content"] = {"updated_key": "updated_value"} + + update_future = thread_pool.submit(asyncio.run, client.update(updated_data)) + _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) + + assert update_request.setup_version_id == created_version.id + + update_context = FakeContext() + update_response = mock_servicer.UpdateSetupVersion(update_request, update_context) + update_rpc.send_initial_metadata(()) + update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") + + result = update_future.result() + assert result is True + + # Verify the update in mock servicer + updated_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ + generate_setup_version_obj.version + ] + assert updated_version.content == {"updated_key": "updated_value"} + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.validation + def test_update_setup_version_not_found( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test updating a non-existent setup version returns False. + + Verifies that attempting to update a non-existent setup version returns False. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + update_method_desc = service_desc.methods_by_name["UpdateSetupVersion"] + + updated_data = generate_setup_version_obj.model_dump() + updated_data["id"] = "nonexistent_version_id" + + update_future = thread_pool.submit(asyncio.run, client.update(updated_data)) + _, update_request, update_rpc = test_channel.take_unary_unary(update_method_desc) + + update_context = FakeContext() + update_response = mock_servicer.UpdateSetupVersion(update_request, update_context) + update_rpc.send_initial_metadata(()) + # When setup version doesn't exist, return OK status with success=False + update_rpc.terminate(update_response, (), grpc.StatusCode.OK, "") + + result = update_future.result() + assert result is False + + +class TestDeleteSetupVersion: + """Tests for delete_setup_version() method. + + Verifies successful deletion of setup versions and proper handling of non-existent versions. + """ + + @freeze_time("2025-04-01 12:00:01") + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.smoke + def test_delete_setup_version_success( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + generate_setup_version_obj: SetupVersionData, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test successfully deleting a setup version. + + Verifies that delete_setup_version removes the version from storage. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + create_method_desc = service_desc.methods_by_name["CreateSetupVersion"] + delete_method_desc = service_desc.methods_by_name["DeleteSetupVersion"] + + # First create a setup version + create_future = thread_pool.submit(asyncio.run, client.create(generate_setup_version_obj.model_dump())) + _, _create_request, create_rpc = test_channel.take_unary_unary(create_method_desc) + create_rpc.send_initial_metadata(()) + request_obj = setup_version_dto_pb2.CreateSetupVersionRequest(**{ + k: v for (k, v) in generate_setup_version_obj.model_dump().items() if k not in {"created_at", "id"} + }) + create_response = mock_servicer.CreateSetupVersion(request_obj, FakeContext()) + create_rpc.terminate(create_response, (), grpc.StatusCode.OK, "") + create_future.result() + + # Get the created version + created_version = mock_servicer.setup_versions[generate_setup_version_obj.setup_id][ + generate_setup_version_obj.version + ] + + # Delete the setup version + delete_future = thread_pool.submit(asyncio.run, client.delete({"setup_version_id": created_version.id})) + _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) + + assert delete_request.setup_version_id == created_version.id + + delete_context = FakeContext() + delete_response = mock_servicer.DeleteSetupVersion(delete_request, delete_context) + delete_rpc.send_initial_metadata(()) + delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") + + result = delete_future.result() + assert result is True + + # Verify deletion in mock servicer + assert generate_setup_version_obj.setup_id not in mock_servicer.setup_versions + + @pytest.mark.grpc + @pytest.mark.integration + @pytest.mark.validation + def test_delete_setup_version_not_found( + self, + client: GrpcSetupVersion, + test_channel: grpc_testing.Channel, + mock_servicer: MockSetupVersionServicer, + thread_pool: futures.ThreadPoolExecutor, + ) -> None: + """Test deleting a non-existent setup version returns False. + + Verifies that attempting to delete a non-existent setup version returns False. + """ + service_desc = setup_version_service_pb2.DESCRIPTOR.services_by_name["SetupVersionService"] + delete_method_desc = service_desc.methods_by_name["DeleteSetupVersion"] + + delete_future = thread_pool.submit(asyncio.run, client.delete({"setup_version_id": "nonexistent_version_id"})) + _, delete_request, delete_rpc = test_channel.take_unary_unary(delete_method_desc) + + delete_context = FakeContext() + delete_response = mock_servicer.DeleteSetupVersion(delete_request, delete_context) + delete_rpc.send_initial_metadata(()) + # When setup version doesn't exist, return OK status with success=False + delete_rpc.terminate(delete_response, (), grpc.StatusCode.OK, "") + + result = delete_future.result() + assert result is False + +# ============================================================================ +# Regression Tests +# ============================================================================ +# This section contains tests for previously identified bugs and edge cases +# that were fixed. Each test should document the issue/PR that it addresses. +# +# Format: +# @pytest.mark.grpc +# @pytest.mark.integration +# @pytest.mark.regression +# def test_regression_issue_123(...): +# """Test for regression of issue #123. +# +# Issue: [Brief description of the bug] +# Fixed in: PR #456 / commit abc123 +# +# Verifies: [What this test checks to prevent regression] +# """ +# +# Add regression tests below as bugs are discovered and fixed. diff --git a/tests/services/storage/mock_storage_servicer.py b/tests/services/storage/mock_storage_servicer.py index 26b0dece..2b85aed2 100644 --- a/tests/services/storage/mock_storage_servicer.py +++ b/tests/services/storage/mock_storage_servicer.py @@ -4,11 +4,13 @@ from typing import Any import grpc -from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2_grpc +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 +from agentic_mesh_protocol.storage.v1 import storage_dto_pb2, storage_service_pb2_grpc, storage_messages_pb2 from google.protobuf import json_format, struct_pb2 from pydantic import BaseModel, ValidationError from digitalkin.logger import logger +from digitalkin.models.services.storage import DataType class MockStorageServicer(storage_service_pb2_grpc.StorageServiceServicer): @@ -45,13 +47,13 @@ def _validate_schema(self, collection: str, data: dict[str, Any]) -> None: # This will raise ValidationError if invalid model_cls.model_validate(data) - def _create_proto_record( - self, - mission_id: str, + @staticmethod + def __create_proto_record( + mission_id: str, collection: str, record_id: str, record_data: dict[str, Any], - ) -> data_pb2.StorageRecord: + ) -> storage_messages_pb2.StorageRecord: """Convert internal record data to proto StorageRecord. Args: @@ -61,7 +63,7 @@ def _create_proto_record( record_data: The record data dictionary Returns: - data_pb2.StorageRecord: Proto storage record + storage_pb2.StorageRecord: Proto storage record """ # Convert data dict to Struct data_struct = json_format.ParseDict( @@ -69,76 +71,67 @@ def _create_proto_record( struct_pb2.Struct(), ) - # Convert stored string name back to protobuf enum value - # "OUTPUT" -> data_pb2.OUTPUT (integer) - value = getattr(data_pb2, record_data["data_type"]) - # Convert ISO timestamp strings to datetime objects for protobuf Timestamp from google.protobuf.timestamp_pb2 import Timestamp creation_ts = Timestamp() update_ts = Timestamp() - if record_data.get("creation_date"): - creation_dt = datetime.datetime.fromisoformat(record_data["creation_date"]) + if record_data.get("created_at"): + creation_dt = datetime.datetime.fromisoformat(record_data["created_at"]) creation_ts.FromDatetime(creation_dt) - if record_data.get("update_date"): - update_dt = datetime.datetime.fromisoformat(record_data["update_date"]) + if record_data.get("updated_at"): + update_dt = datetime.datetime.fromisoformat(record_data["updated_at"]) update_ts.FromDatetime(update_dt) - return data_pb2.StorageRecord( + return storage_messages_pb2.StorageRecord( mission_id=mission_id, collection=collection, record_id=record_id, - data_type=value, + data_type=record_data["data_type"], data=data_struct, - creation_date=creation_ts, - update_date=update_ts, + created_at=creation_ts, + updated_at=update_ts, ) - def StoreRecord( - self, request: data_pb2.StoreRecordRequest, context: grpc.ServicerContext - ) -> data_pb2.StoreRecordResponse: + def CreateRecord( + self, request: storage_dto_pb2.CreateRecordRequest, context: grpc.ServicerContext + ) -> storage_dto_pb2.CreateRecordResponse: """Store a new record in the mock database. Args: - request: StoreRecordRequest containing record data + request: CreateRecordRequest containing record data context: gRPC context Returns: - StoreRecordResponse: Response containing stored record + CreateRecordResponse: Response containing stored record """ try: # Validate required fields if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return data_pb2.StoreRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) if not request.collection: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Collection is required") - return data_pb2.StoreRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) if not request.record_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Record ID is required") - return data_pb2.StoreRecordResponse() - - # Validate data type (request.data_type is a protobuf enum integer value) - # Convert protobuf enum value to enum name for validation - # data_pb2.OUTPUT (int) -> need to check if it's valid - valid_values = [ - data_pb2.OUTPUT, - data_pb2.VIEW, - data_pb2.LOGS, - data_pb2.OTHER, - ] - if request.data_type not in valid_values: + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) + + if DataType.from_proto(request.data_type) not in DataType: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details(f"Invalid data type: {request.data_type}") - return data_pb2.StoreRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) # Convert Struct to dict data_dict = json_format.MessageToDict(request.data, preserving_proto_field_name=True) @@ -150,7 +143,9 @@ def StoreRecord( except (ValidationError, ValueError) as e: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details(f"Schema validation failed: {e!s}") - return data_pb2.StoreRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), + success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) # Check if record already exists mission_records = self.records.setdefault(request.mission_id, {}) @@ -159,62 +154,68 @@ def StoreRecord( if request.record_id in collection_records: context.set_code(grpc.StatusCode.ALREADY_EXISTS) context.set_details(f"Record {request.record_id} already exists in collection {request.collection}") - return data_pb2.StoreRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.ALREADY_EXISTS)), success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) # Store the record # Convert protobuf enum integer value to string name for storage - # data_pb2.OUTPUT -> "OUTPUT" - name = data_pb2.DataType.Name(request.data_type) + # storage_pb2.OUTPUT -> "OUTPUT" + data_type = request.data_type now = datetime.datetime.now(datetime.timezone.utc).isoformat() record_data = { "data": data_dict, - "data_type": name, - "creation_date": now, - "update_date": now, + "data_type": data_type, + "created_at": now, + "updated_at": now, } collection_records[request.record_id] = record_data # Create response - stored_record = self._create_proto_record( + stored_record = self.__create_proto_record( request.mission_id, request.collection, request.record_id, record_data ) logger.info(f"Stored record: {request.record_id} in {request.collection} for mission {request.mission_id}") - return data_pb2.StoreRecordResponse(stored_data=stored_record) + result = storage_messages_pb2.StorageResult(record=stored_record, success=True) + return storage_dto_pb2.CreateRecordResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in StoreRecord: {e}", exc_info=True) - return data_pb2.StoreRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return storage_dto_pb2.CreateRecordResponse(result=result) - def ReadRecord( - self, request: data_pb2.ReadRecordRequest, context: grpc.ServicerContext - ) -> data_pb2.ReadRecordResponse: + def GetRecord( + self, request: storage_dto_pb2.GetRecordRequest, context: grpc.ServicerContext + ) -> storage_dto_pb2.GetRecordResponse: """Read a record from the mock database. Args: - request: ReadRecordRequest containing mission_id, collection, record_id + request: GetRecordRequest containing mission_id, collection, record_id context: gRPC context Returns: - ReadRecordResponse: Response containing the record or empty if not found + GetRecordResponse: Response containing the record or empty if not found """ try: if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return data_pb2.ReadRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.GetRecordResponse(result=result) if not request.collection: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Collection is required") - return data_pb2.ReadRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.GetRecordResponse(result=result) if not request.record_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Record ID is required") - return data_pb2.ReadRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.GetRecordResponse(result=result) # Try to find the record mission_records = self.records.get(request.mission_id, {}) @@ -224,25 +225,28 @@ def ReadRecord( if not record_data: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f"Record {request.record_id} not found in collection {request.collection}") - return data_pb2.ReadRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False) + return storage_dto_pb2.GetRecordResponse(result=result) # Create response - stored_record = self._create_proto_record( + stored_record = self.__create_proto_record( request.mission_id, request.collection, request.record_id, record_data ) logger.info(f"Read record: {request.record_id} from {request.collection}") - return data_pb2.ReadRecordResponse(stored_data=stored_record) + result = storage_messages_pb2.StorageResult(record=stored_record, success=True) + return storage_dto_pb2.GetRecordResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in ReadRecord: {e}", exc_info=True) - return data_pb2.ReadRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return storage_dto_pb2.GetRecordResponse(result=result) def UpdateRecord( - self, request: data_pb2.UpdateRecordRequest, context: grpc.ServicerContext - ) -> data_pb2.UpdateRecordResponse: + self, request: storage_dto_pb2.UpdateRecordRequest, context: grpc.ServicerContext + ) -> storage_dto_pb2.UpdateRecordResponse: """Update an existing record in the mock database. Args: @@ -256,17 +260,20 @@ def UpdateRecord( if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return data_pb2.UpdateRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.UpdateRecordResponse(result=result) if not request.collection: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Collection is required") - return data_pb2.UpdateRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.UpdateRecordResponse(result=result) if not request.record_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Record ID is required") - return data_pb2.UpdateRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.UpdateRecordResponse(result=result) # Try to find the record mission_records = self.records.get(request.mission_id, {}) @@ -276,7 +283,8 @@ def UpdateRecord( if not record_data: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f"Record {request.record_id} not found in collection {request.collection}") - return data_pb2.UpdateRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND)), success=False) + return storage_dto_pb2.UpdateRecordResponse(result=result) # Convert Struct to dict data_dict = json_format.MessageToDict(request.data, preserving_proto_field_name=True) @@ -288,54 +296,61 @@ def UpdateRecord( except (ValidationError, ValueError) as e: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details(f"Schema validation failed: {e!s}") - return data_pb2.UpdateRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), + success=False) + return storage_dto_pb2.UpdateRecordResponse(result=result) # Update the record now = datetime.datetime.now(datetime.timezone.utc).isoformat() record_data["data"] = data_dict - record_data["update_date"] = now + record_data["updated_at"] = now # Create response - stored_record = self._create_proto_record( + stored_record = self.__create_proto_record( request.mission_id, request.collection, request.record_id, record_data ) logger.info(f"Updated record: {request.record_id} in {request.collection}") - return data_pb2.UpdateRecordResponse(stored_data=stored_record) + result = storage_messages_pb2.StorageResult(record=stored_record, success=True) + return storage_dto_pb2.UpdateRecordResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in UpdateRecord: {e}", exc_info=True) - return data_pb2.UpdateRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return storage_dto_pb2.UpdateRecordResponse(result=result) - def RemoveRecord( - self, request: data_pb2.RemoveRecordRequest, context: grpc.ServicerContext - ) -> data_pb2.RemoveRecordResponse: + def DeleteRecord( + self, request: storage_dto_pb2.DeleteRecordRequest, context: grpc.ServicerContext + ) -> storage_dto_pb2.DeleteRecordResponse: """Remove a record from the mock database. Args: - request: RemoveRecordRequest containing mission_id, collection, record_id + request: DeleteRecordRequest containing mission_id, collection, record_id context: gRPC context Returns: - RemoveRecordResponse: Empty response + DeleteRecordResponse: Empty response """ try: if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return data_pb2.RemoveRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.DeleteRecordResponse(result=result) if not request.collection: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Collection is required") - return data_pb2.RemoveRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.DeleteRecordResponse(result=result) if not request.record_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Record ID is required") - return data_pb2.RemoveRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.DeleteRecordResponse(result=result) # Try to find and remove the record mission_records = self.records.get(request.mission_id, {}) @@ -343,23 +358,28 @@ def RemoveRecord( if request.record_id not in collection_records: # Not an error - idempotent delete - logger.debug(f"Record {request.record_id} not found for removal, already removed or never existed") - return data_pb2.RemoveRecordResponse() + msg = f"Record {request.record_id} not found for removal, already removed or never existed" + logger.debug(msg) + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.CANCELLED), message=msg), + success=False) + return storage_dto_pb2.DeleteRecordResponse(result=result) del collection_records[request.record_id] logger.info(f"Removed record: {request.record_id} from {request.collection}") - return data_pb2.RemoveRecordResponse() + result = storage_messages_pb2.StorageResult(success=True) + return storage_dto_pb2.DeleteRecordResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in RemoveRecord: {e}", exc_info=True) - return data_pb2.RemoveRecordResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return storage_dto_pb2.DeleteRecordResponse(result=result) def ListRecords( - self, request: data_pb2.ListRecordsRequest, context: grpc.ServicerContext - ) -> data_pb2.ListRecordsResponse: + self, request: storage_dto_pb2.ListRecordsRequest, context: grpc.ServicerContext + ) -> storage_dto_pb2.ListRecordsResponse: """List all records in a collection. Args: @@ -373,12 +393,14 @@ def ListRecords( if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return data_pb2.ListRecordsResponse(records=[]) + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.ListRecordsResponse(result=result) if not request.collection: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Collection is required") - return data_pb2.ListRecordsResponse(records=[]) + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.ListRecordsResponse(result=result) # Get all records in the collection mission_records = self.records.get(request.mission_id, {}) @@ -387,40 +409,44 @@ def ListRecords( # Convert to proto records proto_records = [] for record_id, record_data in collection_records.items(): - proto_record = self._create_proto_record(request.mission_id, request.collection, record_id, record_data) + proto_record = self.__create_proto_record(request.mission_id, request.collection, record_id, record_data) proto_records.append(proto_record) logger.info(f"Listed {len(proto_records)} records from {request.collection}") - return data_pb2.ListRecordsResponse(records=proto_records) + result = [storage_messages_pb2.StorageResult(record=r, success=True) for r in proto_records] + return storage_dto_pb2.ListRecordsResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in ListRecords: {e}", exc_info=True) - return data_pb2.ListRecordsResponse(records=[]) + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return storage_dto_pb2.ListRecordsResponse(result=[result]) - def RemoveCollection( - self, request: data_pb2.RemoveCollectionRequest, context: grpc.ServicerContext - ) -> data_pb2.RemoveCollectionResponse: + def DeleteCollection( + self, request: storage_dto_pb2.DeleteCollectionRequest, context: grpc.ServicerContext + ) -> storage_dto_pb2.DeleteCollectionResponse: """Remove all records in a collection. Args: - request: RemoveCollectionRequest containing mission_id and collection + request: DeleteCollectionRequest containing mission_id and collection context: gRPC context Returns: - RemoveCollectionResponse: Empty response + DeleteCollectionResponse: Empty response """ try: if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return data_pb2.RemoveCollectionResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.DeleteCollectionResponse(result=result) if not request.collection: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Collection is required") - return data_pb2.RemoveCollectionResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), success=False) + return storage_dto_pb2.DeleteCollectionResponse(result=result) # Remove the entire collection mission_records = self.records.get(request.mission_id, {}) @@ -430,10 +456,12 @@ def RemoveCollection( else: logger.debug(f"Collection {request.collection} not found, already removed or never existed") - return data_pb2.RemoveCollectionResponse() + result = storage_messages_pb2.StorageResult(success=True) + return storage_dto_pb2.DeleteCollectionResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in RemoveCollection: {e}", exc_info=True) - return data_pb2.RemoveCollectionResponse() + result = storage_messages_pb2.StorageResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), success=False) + return storage_dto_pb2.DeleteCollectionResponse(result=result) diff --git a/tests/services/storage/test_grpc_storage.py b/tests/services/storage/test_grpc_storage.py index 3b5f9975..7f01879e 100644 --- a/tests/services/storage/test_grpc_storage.py +++ b/tests/services/storage/test_grpc_storage.py @@ -13,14 +13,15 @@ import grpc import grpc_testing import pytest -from agentic_mesh_protocol.storage.v1 import data_pb2, storage_service_pb2, storage_service_pb2_grpc +from agentic_mesh_protocol.storage.v1 import storage_service_pb2, storage_service_pb2_grpc from pydantic import BaseModel, Field -from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext -from tests.services.storage.mock_storage_servicer import MockStorageServicer +from digitalkin.grpc_servers.utils.exceptions import ServerError from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.services.storage.grpc_storage import GrpcStorage -from digitalkin.services.storage.storage_strategy import DataType, StorageServiceError +from digitalkin.models.services.storage import DataType +from digitalkin.services.storage import GrpcStorage +from tests.fixtures.grpc_fixtures import AsyncStubWrapper, FakeContext +from tests.services.storage.mock_storage_servicer import MockStorageServicer # Set timeout for all tests in this file (20 seconds) pytestmark = pytest.mark.timeout(20) @@ -155,7 +156,7 @@ def client( # ============================================================================ -class TestStoreData: +class TestCreateData: """Tests for the store() method. This test class validates the storage of records with different data types, @@ -165,7 +166,7 @@ class TestStoreData: @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_store_record_success( + def test_create_record_success( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -184,10 +185,10 @@ def test_store_record_success( data = {"mission_id": MISSION_ID, "name": "Test Record", "value": 42, "description": "A test record"} # Get the method descriptor - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] # Execute client call in thread pool - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) # Intercept the call _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -197,13 +198,11 @@ def test_store_record_success( assert request.collection == collection assert request.record_id == record_id # data_type is now a protobuf enum integer value - from agentic_mesh_protocol.storage.v1 import data_pb2 - - assert request.data_type == data_pb2.OUTPUT + assert DataType.from_proto(request.data_type) == DataType.OUTPUT # Mock servicer processes the request context = FakeContext() - response = mock_servicer.StoreRecord(request, context) + response = mock_servicer.CreateRecord(request, context) # Terminate the RPC rpc.send_initial_metadata(()) @@ -220,13 +219,13 @@ def test_store_record_success( assert result.data_type == DataType.OUTPUT assert result.data.name == "Test Record" assert result.data.value == 42 - assert result.creation_date is not None - assert result.update_date is not None + assert result.created_at is not None + assert result.updated_at is not None @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - async def test_store_record_invalid_schema( + async def test_create_record_invalid_schema( self, client: GrpcStorage, ) -> None: @@ -243,12 +242,12 @@ async def test_store_record_invalid_schema( # ValueError is raised client-side during validation, before any gRPC call with pytest.raises(ValueError, match="Validation failed"): - await client.store(collection, record_id, data) + await client.create(collection, record_id, data) @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_store_record_duplicate( + def test_create_record_duplicate( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -265,34 +264,34 @@ def test_store_record_duplicate( record_id = "record_003" data = {"mission_id": MISSION_ID, "name": "First Record", "value": 10} - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] # Store first record - future1 = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + future1 = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, request1, rpc1 = test_channel.take_unary_unary(method_desc) context1 = FakeContext() - response1 = mock_servicer.StoreRecord(request1, context1) + response1 = mock_servicer.CreateRecord(request1, context1) rpc1.send_initial_metadata(()) rpc1.terminate(response1, (), grpc.StatusCode.OK, "") result1 = future1.result(timeout=1.0) assert result1 is not None # Attempt to store duplicate - future2 = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + future2 = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, request2, rpc2 = test_channel.take_unary_unary(method_desc) context2 = FakeContext() - response2 = mock_servicer.StoreRecord(request2, context2) + response2 = mock_servicer.CreateRecord(request2, context2) rpc2.send_initial_metadata(()) rpc2.terminate(response2, (), context2._code, context2._details) # Verify error is raised - with pytest.raises(StorageServiceError): + with pytest.raises(ServerError): future2.result(timeout=1.0) @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_store_record_with_output_type( + def test_create_record_with_output_type( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -309,16 +308,16 @@ def test_store_record_with_output_type( record_id = "output_001" data = {"mission_id": MISSION_ID, "result": "Success", "score": 0.95} - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="OUTPUT")) + future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data, data_type=DataType.OUTPUT)) _, request, rpc = test_channel.take_unary_unary(method_desc) - assert request.data_type == data_pb2.OUTPUT + assert DataType.from_proto(request.data_type) == DataType.OUTPUT context = FakeContext() - response = mock_servicer.StoreRecord(request, context) + response = mock_servicer.CreateRecord(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -328,7 +327,7 @@ def test_store_record_with_output_type( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_store_record_with_logs_type( + def test_create_record_with_logs_type( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -350,16 +349,16 @@ def test_store_record_with_logs_type( "timestamp": "2024-01-01T00:00:00Z", } - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="LOGS")) + future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data, data_type=DataType.LOGS)) _, request, rpc = test_channel.take_unary_unary(method_desc) - assert request.data_type == data_pb2.LOGS + assert DataType.from_proto(request.data_type) == DataType.LOGS context = FakeContext() - response = mock_servicer.StoreRecord(request, context) + response = mock_servicer.CreateRecord(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -369,7 +368,7 @@ def test_store_record_with_logs_type( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_store_record_with_view_type( + def test_create_record_with_view_type( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -386,16 +385,16 @@ def test_store_record_with_view_type( record_id = "view_001" data = {"mission_id": MISSION_ID, "name": "View Data", "value": 100} - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="VIEW")) + future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data, data_type=DataType.VIEW)) _, request, rpc = test_channel.take_unary_unary(method_desc) - assert request.data_type == data_pb2.VIEW + assert DataType.from_proto(request.data_type) == DataType.VIEW context = FakeContext() - response = mock_servicer.StoreRecord(request, context) + response = mock_servicer.CreateRecord(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -405,7 +404,7 @@ def test_store_record_with_view_type( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_store_record_with_other_type( + def test_create_record_with_other_type( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -422,16 +421,16 @@ def test_store_record_with_other_type( record_id = "other_001" data = {"mission_id": MISSION_ID, "name": "Other Data", "value": 50} - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] - future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data, data_type="OTHER")) + future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data, data_type=DataType.OTHER)) _, request, rpc = test_channel.take_unary_unary(method_desc) - assert request.data_type == data_pb2.OTHER + assert DataType.from_proto(request.data_type) == DataType.OTHER context = FakeContext() - response = mock_servicer.StoreRecord(request, context) + response = mock_servicer.CreateRecord(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -441,7 +440,7 @@ def test_store_record_with_other_type( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_store_record_auto_generated_id( + def test_create_record_auto_generated_id( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -457,10 +456,10 @@ def test_store_record_auto_generated_id( collection = "test_collection" data = {"mission_id": MISSION_ID, "name": "Auto ID Record", "value": 999} - method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["StoreRecord"] + method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name["CreateRecord"] # Pass None for record_id to trigger auto-generation - future = thread_pool.submit(asyncio.run, client.store(collection, None, data)) + future = thread_pool.submit(asyncio.run, client.create(collection, None, data)) _, request, rpc = test_channel.take_unary_unary(method_desc) @@ -469,7 +468,7 @@ def test_store_record_auto_generated_id( assert len(request.record_id) > 0 context = FakeContext() - response = mock_servicer.StoreRecord(request, context) + response = mock_servicer.CreateRecord(request, context) rpc.send_initial_metadata(()) rpc.terminate(response, (), grpc.StatusCode.OK, "") @@ -477,7 +476,7 @@ def test_store_record_auto_generated_id( assert result.record_id is not None -class TestRetrieveData: +class TestGetData: """Tests for the retrieve/read() method. This test class validates reading records from storage, handling non-existent @@ -487,7 +486,7 @@ class TestRetrieveData: @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_read_record_success( + def test_get_record_success( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -505,23 +504,23 @@ def test_read_record_success( data = {"mission_id": MISSION_ID, "name": "Read Test", "value": 123} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] read_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "ReadRecord" + "GetRecord" ] # Store the record first - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") store_future.result(timeout=1.0) # Read the record - read_future = thread_pool.submit(asyncio.run, client.read(collection, record_id)) + read_future = thread_pool.submit(asyncio.run, client.get(collection, record_id)) _, read_request, read_rpc = test_channel.take_unary_unary(read_method_desc) assert read_request.mission_id == MISSION_ID @@ -529,7 +528,7 @@ def test_read_record_success( assert read_request.record_id == record_id read_context = FakeContext() - read_response = mock_servicer.ReadRecord(read_request, read_context) + read_response = mock_servicer.GetRecord(read_request, read_context) read_rpc.send_initial_metadata(()) read_rpc.terminate(read_response, (), grpc.StatusCode.OK, "") @@ -542,7 +541,7 @@ def test_read_record_success( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_read_record_not_found( + def test_get_record_not_found( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -559,24 +558,25 @@ def test_read_record_not_found( record_id = "nonexistent_record" read_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "ReadRecord" + "GetRecord" ] - read_future = thread_pool.submit(asyncio.run, client.read(collection, record_id)) + read_future = thread_pool.submit(asyncio.run, client.get(collection, record_id)) _, read_request, read_rpc = test_channel.take_unary_unary(read_method_desc) read_context = FakeContext() - read_response = mock_servicer.ReadRecord(read_request, read_context) + read_response = mock_servicer.GetRecord(read_request, read_context) read_rpc.send_initial_metadata(()) read_rpc.terminate(read_response, (), read_context._code, read_context._details) - result = read_future.result(timeout=1.0) - assert result is None + with pytest.raises(ServerError): + read_future.result(timeout=1.0) + @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_read_record_from_different_collections( + def test_get_record_from_different_collections( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -594,44 +594,44 @@ def test_read_record_from_different_collections( data2 = {"mission_id": MISSION_ID, "result": "Collection 2", "score": 0.8} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] read_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "ReadRecord" + "GetRecord" ] # Store in collection 1 - store_future1 = thread_pool.submit(asyncio.run, client.store("test_collection", record_id, data1)) + store_future1 = thread_pool.submit(asyncio.run, client.create("test_collection", record_id, data1)) _, store_request1, store_rpc1 = test_channel.take_unary_unary(store_method_desc) store_context1 = FakeContext() - store_response1 = mock_servicer.StoreRecord(store_request1, store_context1) + store_response1 = mock_servicer.CreateRecord(store_request1, store_context1) store_rpc1.send_initial_metadata(()) store_rpc1.terminate(store_response1, (), grpc.StatusCode.OK, "") store_future1.result(timeout=1.0) # Store in collection 2 - store_future2 = thread_pool.submit(asyncio.run, client.store("outputs", record_id, data2)) + store_future2 = thread_pool.submit(asyncio.run, client.create("outputs", record_id, data2)) _, store_request2, store_rpc2 = test_channel.take_unary_unary(store_method_desc) store_context2 = FakeContext() - store_response2 = mock_servicer.StoreRecord(store_request2, store_context2) + store_response2 = mock_servicer.CreateRecord(store_request2, store_context2) store_rpc2.send_initial_metadata(()) store_rpc2.terminate(store_response2, (), grpc.StatusCode.OK, "") store_future2.result(timeout=1.0) # Read from collection 1 - read_future1 = thread_pool.submit(asyncio.run, client.read("test_collection", record_id)) + read_future1 = thread_pool.submit(asyncio.run, client.get("test_collection", record_id)) _, read_request1, read_rpc1 = test_channel.take_unary_unary(read_method_desc) read_context1 = FakeContext() - read_response1 = mock_servicer.ReadRecord(read_request1, read_context1) + read_response1 = mock_servicer.GetRecord(read_request1, read_context1) read_rpc1.send_initial_metadata(()) read_rpc1.terminate(read_response1, (), grpc.StatusCode.OK, "") result1 = read_future1.result(timeout=1.0) # Read from collection 2 - read_future2 = thread_pool.submit(asyncio.run, client.read("outputs", record_id)) + read_future2 = thread_pool.submit(asyncio.run, client.get("outputs", record_id)) _, read_request2, read_rpc2 = test_channel.take_unary_unary(read_method_desc) read_context2 = FakeContext() - read_response2 = mock_servicer.ReadRecord(read_request2, read_context2) + read_response2 = mock_servicer.GetRecord(read_request2, read_context2) read_rpc2.send_initial_metadata(()) read_rpc2.terminate(read_response2, (), grpc.StatusCode.OK, "") result2 = read_future2.result(timeout=1.0) @@ -673,17 +673,17 @@ def test_update_record_success( updated_data = {"mission_id": MISSION_ID, "name": "Updated", "value": 200} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] update_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ "UpdateRecord" ] # Store the record first - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, original_data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, original_data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") store_result = store_future.result(timeout=1.0) @@ -707,7 +707,7 @@ def test_update_record_success( assert result.data.name == "Updated" assert result.data.value == 200 # Update timestamp should be later than creation timestamp - assert result.update_date != store_result.update_date + assert result.updated_at != store_result.updated_at @pytest.mark.grpc @pytest.mark.integration @@ -741,8 +741,8 @@ def test_update_record_not_found( update_rpc.send_initial_metadata(()) update_rpc.terminate(update_response, (), update_context._code, update_context._details) - result = update_future.result(timeout=1.0) - assert result is None + with pytest.raises(ServerError): + update_future.result(timeout=1.0) @pytest.mark.grpc @pytest.mark.integration @@ -777,7 +777,7 @@ class TestDeleteData: @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_remove_record_success( + def test_delete_record_success( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -796,26 +796,26 @@ def test_remove_record_success( data = {"mission_id": MISSION_ID, "name": "To be removed", "value": 999} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] remove_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "RemoveRecord" + "DeleteRecord" ] read_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "ReadRecord" + "GetRecord" ] # Store the record first - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") store_future.result(timeout=1.0) # Remove the record - remove_future = thread_pool.submit(asyncio.run, client.remove(collection, record_id)) + remove_future = thread_pool.submit(asyncio.run, client.delete(collection, record_id)) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_method_desc) assert remove_request.mission_id == MISSION_ID @@ -823,7 +823,7 @@ def test_remove_record_success( assert remove_request.record_id == record_id remove_context = FakeContext() - remove_response = mock_servicer.RemoveRecord(remove_request, remove_context) + remove_response = mock_servicer.DeleteRecord(remove_request, remove_context) remove_rpc.send_initial_metadata(()) remove_rpc.terminate(remove_response, (), grpc.StatusCode.OK, "") @@ -831,21 +831,21 @@ def test_remove_record_success( assert result is True # Try to read the removed record - read_future = thread_pool.submit(asyncio.run, client.read(collection, record_id)) + read_future = thread_pool.submit(asyncio.run, client.get(collection, record_id)) _, read_request, read_rpc = test_channel.take_unary_unary(read_method_desc) read_context = FakeContext() - read_response = mock_servicer.ReadRecord(read_request, read_context) + read_response = mock_servicer.GetRecord(read_request, read_context) read_rpc.send_initial_metadata(()) read_rpc.terminate(read_response, (), grpc.StatusCode.NOT_FOUND, "Record not found") # Should return None for non-existent record - read_result = read_future.result(timeout=1.0) - assert read_result is None + with pytest.raises(ServerError): + read_future.result(timeout=1.0) @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_remove_record_not_found( + def test_delete_record_not_found( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -862,25 +862,25 @@ def test_remove_record_not_found( record_id = "nonexistent_remove" remove_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "RemoveRecord" + "DeleteRecord" ] - remove_future = thread_pool.submit(asyncio.run, client.remove(collection, record_id)) + remove_future = thread_pool.submit(asyncio.run, client.delete(collection, record_id)) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_method_desc) remove_context = FakeContext() # Mock servicer should return success even if record doesn't exist (idempotent) - remove_response = mock_servicer.RemoveRecord(remove_request, remove_context) + remove_response = mock_servicer.DeleteRecord(remove_request, remove_context) remove_rpc.send_initial_metadata(()) remove_rpc.terminate(remove_response, (), grpc.StatusCode.OK, "") result = remove_future.result(timeout=1.0) - assert result is True + assert result is False @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_remove_record_twice( + def test_delete_record_twice( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -898,46 +898,46 @@ def test_remove_record_twice( data = {"mission_id": MISSION_ID, "name": "Remove twice", "value": 888} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] remove_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "RemoveRecord" + "DeleteRecord" ] # Store the record - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") store_future.result(timeout=1.0) # Remove the record first time - remove_future1 = thread_pool.submit(asyncio.run, client.remove(collection, record_id)) + remove_future1 = thread_pool.submit(asyncio.run, client.delete(collection, record_id)) _, remove_request1, remove_rpc1 = test_channel.take_unary_unary(remove_method_desc) remove_context1 = FakeContext() - remove_response1 = mock_servicer.RemoveRecord(remove_request1, remove_context1) + remove_response1 = mock_servicer.DeleteRecord(remove_request1, remove_context1) remove_rpc1.send_initial_metadata(()) remove_rpc1.terminate(remove_response1, (), grpc.StatusCode.OK, "") result1 = remove_future1.result(timeout=1.0) # Remove the record second time - remove_future2 = thread_pool.submit(asyncio.run, client.remove(collection, record_id)) + remove_future2 = thread_pool.submit(asyncio.run, client.delete(collection, record_id)) _, remove_request2, remove_rpc2 = test_channel.take_unary_unary(remove_method_desc) remove_context2 = FakeContext() - remove_response2 = mock_servicer.RemoveRecord(remove_request2, remove_context2) + remove_response2 = mock_servicer.DeleteRecord(remove_request2, remove_context2) remove_rpc2.send_initial_metadata(()) remove_rpc2.terminate(remove_response2, (), grpc.StatusCode.OK, "") result2 = remove_future2.result(timeout=1.0) assert result1 is True - assert result2 is True + assert result2 is False @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - def test_remove_collection_success( + def test_delete_collection_success( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -958,10 +958,10 @@ def test_remove_collection_success( ] store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] remove_coll_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "RemoveCollection" + "DeleteCollection" ] list_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ "ListRecords" @@ -970,23 +970,23 @@ def test_remove_collection_success( # Store multiple records for idx, data in enumerate(records_data): record_id = f"record_coll_{idx}" - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") store_future.result(timeout=1.0) # Remove the collection - remove_future = thread_pool.submit(asyncio.run, client.remove_collection(collection)) + remove_future = thread_pool.submit(asyncio.run, client.delete_collection(collection)) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_coll_method_desc) assert remove_request.mission_id == MISSION_ID assert remove_request.collection == collection remove_context = FakeContext() - remove_response = mock_servicer.RemoveCollection(remove_request, remove_context) + remove_response = mock_servicer.DeleteCollection(remove_request, remove_context) remove_rpc.send_initial_metadata(()) remove_rpc.terminate(remove_response, (), grpc.StatusCode.OK, "") @@ -1007,7 +1007,7 @@ def test_remove_collection_success( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.validation - def test_remove_collection_not_found( + def test_delete_collection_not_found( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -1023,14 +1023,14 @@ def test_remove_collection_not_found( collection = "nonexistent_collection" remove_coll_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "RemoveCollection" + "DeleteCollection" ] - remove_future = thread_pool.submit(asyncio.run, client.remove_collection(collection)) + remove_future = thread_pool.submit(asyncio.run, client.delete_collection(collection)) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_coll_method_desc) remove_context = FakeContext() - remove_response = mock_servicer.RemoveCollection(remove_request, remove_context) + remove_response = mock_servicer.DeleteCollection(remove_request, remove_context) remove_rpc.send_initial_metadata(()) remove_rpc.terminate(remove_response, (), grpc.StatusCode.OK, "") @@ -1040,7 +1040,7 @@ def test_remove_collection_not_found( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_remove_collection_isolation( + def test_delete_collection_isolation( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -1057,37 +1057,37 @@ def test_remove_collection_isolation( data2 = {"mission_id": MISSION_ID, "result": "Collection 2", "score": 0.9} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] remove_coll_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "RemoveCollection" + "DeleteCollection" ] list_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ "ListRecords" ] # Store in both collections - store_future1 = thread_pool.submit(asyncio.run, client.store("test_collection", "rec1", data1)) + store_future1 = thread_pool.submit(asyncio.run, client.create("test_collection", "rec1", data1)) _, store_request1, store_rpc1 = test_channel.take_unary_unary(store_method_desc) store_context1 = FakeContext() - store_response1 = mock_servicer.StoreRecord(store_request1, store_context1) + store_response1 = mock_servicer.CreateRecord(store_request1, store_context1) store_rpc1.send_initial_metadata(()) store_rpc1.terminate(store_response1, (), grpc.StatusCode.OK, "") store_future1.result(timeout=1.0) - store_future2 = thread_pool.submit(asyncio.run, client.store("outputs", "rec2", data2)) + store_future2 = thread_pool.submit(asyncio.run, client.create("outputs", "rec2", data2)) _, store_request2, store_rpc2 = test_channel.take_unary_unary(store_method_desc) store_context2 = FakeContext() - store_response2 = mock_servicer.StoreRecord(store_request2, store_context2) + store_response2 = mock_servicer.CreateRecord(store_request2, store_context2) store_rpc2.send_initial_metadata(()) store_rpc2.terminate(store_response2, (), grpc.StatusCode.OK, "") store_future2.result(timeout=1.0) # Remove collection 1 - remove_future = thread_pool.submit(asyncio.run, client.remove_collection("test_collection")) + remove_future = thread_pool.submit(asyncio.run, client.delete_collection("test_collection")) _, remove_request, remove_rpc = test_channel.take_unary_unary(remove_coll_method_desc) remove_context = FakeContext() - remove_response = mock_servicer.RemoveCollection(remove_request, remove_context) + remove_response = mock_servicer.DeleteCollection(remove_request, remove_context) remove_rpc.send_initial_metadata(()) remove_rpc.terminate(remove_response, (), grpc.StatusCode.OK, "") remove_future.result(timeout=1.0) @@ -1137,7 +1137,7 @@ def test_list_records_success( ] store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] list_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ "ListRecords" @@ -1146,10 +1146,10 @@ def test_list_records_success( # Store multiple records for idx, data in enumerate(records_data): record_id = f"record_list_{idx}" - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") store_future.result(timeout=1.0) @@ -1225,26 +1225,26 @@ def test_list_records_multiple_collections( data2 = {"mission_id": MISSION_ID, "result": "Collection 2", "score": 0.75} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] list_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ "ListRecords" ] # Store in collection 1 - store_future1 = thread_pool.submit(asyncio.run, client.store("test_collection", "rec1", data1)) + store_future1 = thread_pool.submit(asyncio.run, client.create("test_collection", "rec1", data1)) _, store_request1, store_rpc1 = test_channel.take_unary_unary(store_method_desc) store_context1 = FakeContext() - store_response1 = mock_servicer.StoreRecord(store_request1, store_context1) + store_response1 = mock_servicer.CreateRecord(store_request1, store_context1) store_rpc1.send_initial_metadata(()) store_rpc1.terminate(store_response1, (), grpc.StatusCode.OK, "") store_future1.result(timeout=1.0) # Store in collection 2 - store_future2 = thread_pool.submit(asyncio.run, client.store("outputs", "rec2", data2)) + store_future2 = thread_pool.submit(asyncio.run, client.create("outputs", "rec2", data2)) _, store_request2, store_rpc2 = test_channel.take_unary_unary(store_method_desc) store_context2 = FakeContext() - store_response2 = mock_servicer.StoreRecord(store_request2, store_context2) + store_response2 = mock_servicer.CreateRecord(store_request2, store_context2) store_rpc2.send_initial_metadata(()) store_rpc2.terminate(store_response2, (), grpc.StatusCode.OK, "") store_future2.result(timeout=1.0) @@ -1273,7 +1273,7 @@ class TestStorageEdgeCases: @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_store_record_with_special_characters( + def test_create_record_with_special_characters( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -1296,13 +1296,13 @@ def test_store_record_with_special_characters( } store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") @@ -1314,7 +1314,7 @@ def test_store_record_with_special_characters( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.edge_case - def test_store_record_with_large_data( + def test_create_record_with_large_data( self, client: GrpcStorage, test_channel: grpc_testing.Channel, @@ -1333,13 +1333,13 @@ def test_store_record_with_large_data( data = {"mission_id": MISSION_ID, "name": "Large Data Record", "value": 999, "description": large_description} store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] - store_future = thread_pool.submit(asyncio.run, client.store(collection, record_id, data)) + store_future = thread_pool.submit(asyncio.run, client.create(collection, record_id, data)) _, store_request, store_rpc = test_channel.take_unary_unary(store_method_desc) store_context = FakeContext() - store_response = mock_servicer.StoreRecord(store_request, store_context) + store_response = mock_servicer.CreateRecord(store_request, store_context) store_rpc.send_initial_metadata(()) store_rpc.terminate(store_response, (), grpc.StatusCode.OK, "") @@ -1378,46 +1378,46 @@ def test_mission_isolation( record_id = "shared_record_id" store_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "StoreRecord" + "CreateRecord" ] read_method_desc = storage_service_pb2.DESCRIPTOR.services_by_name["StorageService"].methods_by_name[ - "ReadRecord" + "GetRecord" ] # Store with client1 data1 = {"mission_id": mission1_id, "name": "Mission 1 Data", "value": 100} - store_future1 = thread_pool.submit(asyncio.run, client1.store(collection, record_id, data1)) + store_future1 = thread_pool.submit(asyncio.run, client1.create(collection, record_id, data1)) _, store_request1, store_rpc1 = test_channel.take_unary_unary(store_method_desc) store_context1 = FakeContext() - store_response1 = mock_servicer.StoreRecord(store_request1, store_context1) + store_response1 = mock_servicer.CreateRecord(store_request1, store_context1) store_rpc1.send_initial_metadata(()) store_rpc1.terminate(store_response1, (), grpc.StatusCode.OK, "") result1 = store_future1.result(timeout=1.0) # Store with client2 data2 = {"mission_id": mission2_id, "name": "Mission 2 Data", "value": 200} - store_future2 = thread_pool.submit(asyncio.run, client2.store(collection, record_id, data2)) + store_future2 = thread_pool.submit(asyncio.run, client2.create(collection, record_id, data2)) _, store_request2, store_rpc2 = test_channel.take_unary_unary(store_method_desc) store_context2 = FakeContext() - store_response2 = mock_servicer.StoreRecord(store_request2, store_context2) + store_response2 = mock_servicer.CreateRecord(store_request2, store_context2) store_rpc2.send_initial_metadata(()) store_rpc2.terminate(store_response2, (), grpc.StatusCode.OK, "") result2 = store_future2.result(timeout=1.0) # Read with client1 - read_future1 = thread_pool.submit(asyncio.run, client1.read(collection, record_id)) + read_future1 = thread_pool.submit(asyncio.run, client1.get(collection, record_id)) _, read_request1, read_rpc1 = test_channel.take_unary_unary(read_method_desc) read_context1 = FakeContext() - read_response1 = mock_servicer.ReadRecord(read_request1, read_context1) + read_response1 = mock_servicer.GetRecord(read_request1, read_context1) read_rpc1.send_initial_metadata(()) read_rpc1.terminate(read_response1, (), grpc.StatusCode.OK, "") read_result1 = read_future1.result(timeout=1.0) # Read with client2 - read_future2 = thread_pool.submit(asyncio.run, client2.read(collection, record_id)) + read_future2 = thread_pool.submit(asyncio.run, client2.get(collection, record_id)) _, read_request2, read_rpc2 = test_channel.take_unary_unary(read_method_desc) read_context2 = FakeContext() - read_response2 = mock_servicer.ReadRecord(read_request2, read_context2) + read_response2 = mock_servicer.GetRecord(read_request2, read_context2) read_rpc2.send_initial_metadata(()) read_rpc2.terminate(read_response2, (), grpc.StatusCode.OK, "") read_result2 = read_future2.result(timeout=1.0) @@ -1431,7 +1431,7 @@ def test_mission_isolation( @pytest.mark.grpc @pytest.mark.integration @pytest.mark.smoke - async def test_store_with_no_schema_configured( + async def test_create_record_invalid_schema( self, client: GrpcStorage, ) -> None: @@ -1448,7 +1448,7 @@ async def test_store_with_no_schema_configured( # ValueError is raised client-side during validation, before any gRPC call # So we don't need to intercept the gRPC channel with pytest.raises(ValueError, match="No schema registered for collection"): - await client.store(collection, record_id, data) + await client.create(collection, record_id, data) # Note: TestSearchData is intentionally not included as the current implementation diff --git a/tests/services/storage/test_storage_strategy_locks.py b/tests/services/storage/test_storage_strategy_locks.py index b3730783..a303779f 100644 --- a/tests/services/storage/test_storage_strategy_locks.py +++ b/tests/services/storage/test_storage_strategy_locks.py @@ -1,11 +1,14 @@ """Tests for StorageStrategy lock creation and cleanup.""" import asyncio +from typing import Any +from uuid import uuid4 import pytest from pydantic import BaseModel, Field -from digitalkin.services.storage.storage_strategy import StorageRecord, StorageStrategy +from digitalkin.models.services.storage import DataType, StorageRecord +from digitalkin.services.storage.storage_strategy import StorageStrategy class _SimpleModel(BaseModel): @@ -22,14 +25,28 @@ def __init__(self) -> None: super().__init__("m1", "s1", "sv1", {"items": _SimpleModel}) self._store_data: dict[str, StorageRecord] = {} - async def _store(self, record: StorageRecord) -> StorageRecord: - self._store_data[f"{record.collection}:{record.record_id}"] = record + async def create( + self, + collection: str, + record_id: str | None, + data: BaseModel, + data_type: DataType = DataType.OUTPUT, + ) -> StorageRecord: + """Create a new record.""" + record_id = record_id or uuid4().hex + validated_data = self._validate_data(collection, {**data} if isinstance(data, dict) else data.model_dump()) + record = self._create_storage_record(collection, record_id, validated_data, data_type) + key = f"{collection}:{record_id}" + self._store_data[key] = record + self._record_lock(collection, record_id) return record - async def _read(self, collection: str, record_id: str) -> StorageRecord | None: + async def get(self, collection: str, record_id: str) -> StorageRecord | None: + """Get a record.""" return self._store_data.get(f"{collection}:{record_id}") - async def _update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: + async def update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None: + """Update a record.""" key = f"{collection}:{record_id}" rec = self._store_data.get(key) if rec is None: @@ -37,19 +54,39 @@ async def _update(self, collection: str, record_id: str, data: BaseModel) -> Sto rec.data = data return rec - async def _remove(self, collection: str, record_id: str) -> bool: - return self._store_data.pop(f"{collection}:{record_id}", None) is not None + async def delete(self, collection: str, record_id: str) -> bool: + """Delete a record and clean up its lock.""" + key = f"{collection}:{record_id}" + removed = self._store_data.pop(key, None) is not None + if removed: + self._record_locks.pop(key, None) + return removed - async def _list(self, collection: str) -> list[StorageRecord]: + async def list(self, collection: str) -> list[StorageRecord]: + """List records in a collection.""" return [r for k, r in self._store_data.items() if k.startswith(f"{collection}:")] - async def _remove_collection(self, collection: str) -> bool: + async def delete_collection(self, collection: str) -> bool: + """Delete all records in a collection and clean up locks.""" prefix = f"{collection}:" keys = [k for k in self._store_data if k.startswith(prefix)] for k in keys: del self._store_data[k] + lock_keys = [k for k in self._record_locks if k.startswith(prefix)] + for k in lock_keys: + del self._record_locks[k] return bool(keys) + async def search(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented.""" + msg = "Search method not implemented yet." + raise NotImplementedError(msg) + + async def upload(self, *args: Any, **kwargs: Any) -> Any: + """Not implemented.""" + msg = "Upload method not implemented yet." + raise NotImplementedError(msg) + class TestRecordLockAtomicity: """Tests for atomic lock creation via setdefault.""" @@ -78,10 +115,10 @@ class TestRecordLockCleanup: async def test_remove_cleans_up_lock(self) -> None: """Removing a record also removes its lock entry.""" storage = _InMemoryStorage() - await storage.store("items", "r1", {"value": "x"}) + await storage.create("items", "r1", _SimpleModel(value="x")) assert "items:r1" in storage._record_locks - result = await storage.remove("items", "r1") + result = await storage.delete("items", "r1") assert result is True assert "items:r1" not in storage._record_locks @@ -94,7 +131,7 @@ async def test_remove_nonexistent_keeps_lock(self) -> None: storage._record_lock("items", "r1") assert "items:r1" in storage._record_locks - result = await storage.remove("items", "r1") + result = await storage.delete("items", "r1") assert result is False assert "items:r1" in storage._record_locks @@ -103,12 +140,12 @@ async def test_remove_nonexistent_keeps_lock(self) -> None: async def test_remove_collection_cleans_up_locks(self) -> None: """Removing a collection removes all locks for that collection prefix.""" storage = _InMemoryStorage() - await storage.store("items", "r1", {"value": "a"}) - await storage.store("items", "r2", {"value": "b"}) - await storage.store("items", "r3", {"value": "c"}) + await storage.create("items", "r1", _SimpleModel(value="a")) + await storage.create("items", "r2", _SimpleModel(value="b")) + await storage.create("items", "r3", _SimpleModel(value="c")) assert len([k for k in storage._record_locks if k.startswith("items:")]) == 3 - result = await storage.remove_collection("items") + result = await storage.delete_collection("items") assert result is True assert not any(k.startswith("items:") for k in storage._record_locks) @@ -118,10 +155,10 @@ async def test_remove_collection_preserves_other_collection_locks(self) -> None: """Removing one collection does not affect locks for other collections.""" storage = _InMemoryStorage() storage.config["other"] = _SimpleModel - await storage.store("items", "r1", {"value": "a"}) - await storage.store("other", "r1", {"value": "b"}) + await storage.create("items", "r1", _SimpleModel(value="a")) + await storage.create("other", "r1", _SimpleModel(value="b")) - await storage.remove_collection("items") + await storage.delete_collection("items") assert "items:r1" not in storage._record_locks assert "other:r1" in storage._record_locks diff --git a/tests/services/task_manager/test_grpc_task_manager.py b/tests/services/task_manager/test_grpc_task_manager.py index 7d1dce3e..b5df66c8 100644 --- a/tests/services/task_manager/test_grpc_task_manager.py +++ b/tests/services/task_manager/test_grpc_task_manager.py @@ -108,9 +108,9 @@ def client(test_channel: grpc_testing.Channel) -> GrpcTaskManager: ) client = GrpcTaskManager( - mission_id=MISSION_ID, - setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, + MISSION_ID, + SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.1, ) @@ -569,8 +569,8 @@ async def test_subscribe_returns_sub_id_and_generator(self) -> None: mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( - mission_id=MISSION_ID, setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, + MISSION_ID, SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.1, ) # Mock stub.GetSignals (SharedPoller calls stub directly) @@ -596,8 +596,8 @@ async def test_unsubscribe_stops_polling(self) -> None: mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( - mission_id=MISSION_ID, setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, + MISSION_ID, SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.05, ) @@ -632,8 +632,8 @@ async def test_subscribe_yields_signals(self) -> None: mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( - mission_id=MISSION_ID, setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, + MISSION_ID, SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.05, ) @@ -854,8 +854,8 @@ async def test_concurrent_subscriptions_independent(self) -> None: mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( - mission_id=MISSION_ID, setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, + MISSION_ID, SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.05, ) @@ -896,8 +896,8 @@ async def test_close_stops_all_subscriptions(self) -> None: mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( - mission_id=MISSION_ID, setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, + MISSION_ID, SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.05, ) @@ -931,8 +931,8 @@ async def test_close_idempotent(self) -> None: mode=ServerMode.ASYNC, security=SecurityMode.INSECURE, ) client = GrpcTaskManager( - mission_id=MISSION_ID, setup_id=SETUP_ID, - setup_version_id=SETUP_VERSION_ID, client_config=dummy_config, + MISSION_ID, SETUP_ID, + SETUP_VERSION_ID, client_config=dummy_config, poll_interval=0.05, ) diff --git a/tests/services/task_manager/test_shared_poller_advanced.py b/tests/services/task_manager/test_shared_poller_advanced.py index a957fdee..ca6b7085 100644 --- a/tests/services/task_manager/test_shared_poller_advanced.py +++ b/tests/services/task_manager/test_shared_poller_advanced.py @@ -62,9 +62,9 @@ def _proto(task_id: str, action: str, ts: int | None = None) -> task_manager_mes def _client(poll_interval: float = 0.1, initial: float = 0.05) -> GrpcTaskManager: cfg = ClientConfig(host="[::]", port=50051, mode=ServerMode.ASYNC, security=SecurityMode.INSECURE) c = GrpcTaskManager( - mission_id=_MISSION, - setup_id=_SETUP, - setup_version_id=_VERSION, + _MISSION, + _SETUP, + _VERSION, client_config=cfg, poll_interval=poll_interval, initial_poll_interval=initial, diff --git a/tests/services/user_profile/mock_user_profile_servicer.py b/tests/services/user_profile/mock_user_profile_servicer.py index 016e037b..810dbf55 100644 --- a/tests/services/user_profile/mock_user_profile_servicer.py +++ b/tests/services/user_profile/mock_user_profile_servicer.py @@ -1,9 +1,11 @@ """Mock UserProfile Servicer for testing the GrpcUserProfile service.""" import grpc +from agentic_mesh_protocol.pagination.v1 import bulk_pb2 from agentic_mesh_protocol.user_profile.v1 import ( - user_profile_pb2, + user_profile_dto_pb2, user_profile_service_pb2_grpc, + user_profile_messages_pb2 ) from digitalkin.logger import logger @@ -16,9 +18,9 @@ def __init__(self) -> None: """Initialize the mock servicer with empty user profile storage.""" super().__init__() # mission_id -> user_profile proto response - self.user_profiles: dict[str, user_profile_pb2.GetUserProfileResponse] = {} + self.user_profiles: dict[str, user_profile_dto_pb2.GetUserProfileResponse] = {} - def add_user_profile(self, mission_id: str, response: user_profile_pb2.GetUserProfileResponse) -> None: + def add_user_profile(self, mission_id: str, response: user_profile_dto_pb2.GetUserProfileResponse) -> None: """Add a user profile response to the mock storage. Args: @@ -29,8 +31,8 @@ def add_user_profile(self, mission_id: str, response: user_profile_pb2.GetUserPr logger.debug(f"Added user profile for mission_id: {mission_id}") def GetUserProfile( - self, request: user_profile_pb2.GetUserProfileRequest, context: grpc.ServicerContext - ) -> user_profile_pb2.GetUserProfileResponse: + self, request: user_profile_dto_pb2.GetUserProfileRequest, context: grpc.ServicerContext + ) -> user_profile_dto_pb2.GetUserProfileResponse: """Get a user profile by mission_id. Args: @@ -44,21 +46,28 @@ def GetUserProfile( if not request.mission_id: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Mission ID is required") - return user_profile_pb2.GetUserProfileResponse(success=False) + result = user_profile_messages_pb2.UserProfileResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INVALID_ARGUMENT)), + success=False) + return user_profile_dto_pb2.GetUserProfileResponse(result=result) # Try to find the user profile response = self.user_profiles.get(request.mission_id) - if not response: + if not response.result.success: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f"User profile for mission_id {request.mission_id} not found") - return user_profile_pb2.GetUserProfileResponse(success=False) + result = user_profile_messages_pb2.UserProfileResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.NOT_FOUND)), + success=False) + return user_profile_dto_pb2.GetUserProfileResponse(result=result) logger.info(f"Retrieved user profile for mission_id: {request.mission_id}") - return response + result = user_profile_messages_pb2.UserProfileResult(profile=response.result.profile, success=True) + return user_profile_dto_pb2.GetUserProfileResponse(result=result) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Internal error: {e!s}") logger.error(f"Error in GetUserProfile: {e}", exc_info=True) - return user_profile_pb2.GetUserProfileResponse(success=False) + result = user_profile_messages_pb2.UserProfileResult(error=bulk_pb2.OperationError(code=str(grpc.StatusCode.INTERNAL)), + success=False) + return user_profile_dto_pb2.GetUserProfileResponse(result=result) diff --git a/tests/services/user_profile/test_grpc_user_profile.py b/tests/services/user_profile/test_grpc_user_profile.py index b5a86054..fd4a7adb 100644 --- a/tests/services/user_profile/test_grpc_user_profile.py +++ b/tests/services/user_profile/test_grpc_user_profile.py @@ -16,15 +16,16 @@ import grpc_testing import pytest from agentic_mesh_protocol.user_profile.v1 import ( - user_profile_pb2, + user_profile_dto_pb2, + user_profile_messages_pb2, user_profile_service_pb2, user_profile_service_pb2_grpc, ) -from tests.fixtures.grpc_fixtures import FakeContext -from tests.services.user_profile.mock_user_profile_servicer import MockUserProfileServicer from digitalkin.models.grpc_servers.models import ClientConfig -from digitalkin.services.user_profile.grpc_user_profile import GrpcUserProfile +from digitalkin.services.user_profile.user_profile_grpc import GrpcUserProfile +from tests.fixtures.grpc_fixtures import FakeContext +from tests.services.user_profile.mock_user_profile_servicer import MockUserProfileServicer # Set timeout for all tests in this file (20 seconds) pytestmark = pytest.mark.timeout(20) @@ -32,7 +33,7 @@ # --- Test Constants --- MISSION_ID = "missions:test_mission_123" USER_ID = "users:test_user_123" -ORGANISATION_ID = "organisations:test_org_456" +organization_id = "organizations:test_org_456" # Module-level variables required by grpc_test_server fixture service_instance = MockUserProfileServicer() @@ -140,25 +141,25 @@ def client( @pytest.fixture -def sample_user_profile_response() -> user_profile_pb2.GetUserProfileResponse: +def sample_user_profile_response() -> user_profile_dto_pb2.GetUserProfileResponse: """Create a sample user profile response proto for testing. Returns: GetUserProfileResponse proto """ - user_profile = user_profile_pb2.UserProfile( + user_profile = user_profile_messages_pb2.UserProfile( user_id=USER_ID, - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="test.user@example.com", first_name="Test", last_name="User", locale="en_US", - subscription=user_profile_pb2.Subscription( + subscription=user_profile_messages_pb2.Subscription( tier="premium", status="active", ), credits=[ - user_profile_pb2.CreditLot( + user_profile_messages_pb2.CreditLot( source="subscription", total=1000, remaining=750.0, @@ -166,7 +167,8 @@ def sample_user_profile_response() -> user_profile_pb2.GetUserProfileResponse: ], metadata={"security_key": "test_security_key_123"}, ) - return user_profile_pb2.GetUserProfileResponse(success=True, user_profile=user_profile) + result = user_profile_messages_pb2.UserProfileResult(profile=user_profile, success=True) + return user_profile_dto_pb2.GetUserProfileResponse(result=result) # ============================================================================ @@ -185,7 +187,7 @@ def test_get_user_profile_success( client: GrpcUserProfile, test_channel: grpc_testing.Channel, mock_servicer: MockUserProfileServicer, - sample_user_profile_response: user_profile_pb2.GetUserProfileResponse, + sample_user_profile_response: user_profile_dto_pb2.GetUserProfileResponse, thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test successfully retrieving a user profile.""" @@ -195,7 +197,7 @@ def test_get_user_profile_success( "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) assert request.mission_id == MISSION_ID @@ -208,7 +210,7 @@ def test_get_user_profile_success( assert result is not None assert result["user_id"] == USER_ID - assert result["organisation_id"] == ORGANISATION_ID + assert result["organization_id"] == organization_id assert result["email"] == "test.user@example.com" assert result["first_name"] == "Test" assert result["last_name"] == "User" @@ -222,7 +224,7 @@ def test_get_user_profile_with_subscription( client: GrpcUserProfile, test_channel: grpc_testing.Channel, mock_servicer: MockUserProfileServicer, - sample_user_profile_response: user_profile_pb2.GetUserProfileResponse, + sample_user_profile_response: user_profile_dto_pb2.GetUserProfileResponse, thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with subscription data.""" @@ -232,7 +234,7 @@ def test_get_user_profile_with_subscription( "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -255,7 +257,7 @@ def test_get_user_profile_with_credits( client: GrpcUserProfile, test_channel: grpc_testing.Channel, mock_servicer: MockUserProfileServicer, - sample_user_profile_response: user_profile_pb2.GetUserProfileResponse, + sample_user_profile_response: user_profile_dto_pb2.GetUserProfileResponse, thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with credits data.""" @@ -265,7 +267,7 @@ def test_get_user_profile_with_credits( "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -289,7 +291,7 @@ def test_get_user_profile_with_metadata( client: GrpcUserProfile, test_channel: grpc_testing.Channel, mock_servicer: MockUserProfileServicer, - sample_user_profile_response: user_profile_pb2.GetUserProfileResponse, + sample_user_profile_response: user_profile_dto_pb2.GetUserProfileResponse, thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with metadata.""" @@ -299,7 +301,7 @@ def test_get_user_profile_with_metadata( "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -333,7 +335,7 @@ def test_get_user_profile_not_found( "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -355,19 +357,20 @@ def test_get_user_profile_with_minimal_data( thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with minimal required fields.""" - minimal_profile = user_profile_pb2.UserProfile( + minimal_profile = user_profile_messages_pb2.UserProfile( user_id=USER_ID, - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="minimal@example.com", ) - minimal_response = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=minimal_profile) + result = user_profile_messages_pb2.UserProfileResult(profile=minimal_profile, success=True) + minimal_response = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(MISSION_ID, minimal_response) method_desc = user_profile_service_pb2.DESCRIPTOR.services_by_name["UserProfileService"].methods_by_name[ "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -395,19 +398,20 @@ def test_get_user_profile_with_special_characters_in_email( thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with special characters in email.""" - profile = user_profile_pb2.UserProfile( + profile = user_profile_messages_pb2.UserProfile( user_id=USER_ID, - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="test.user+tag@example.co.uk", ) - response = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile) + result = user_profile_messages_pb2.UserProfileResult(profile=profile, success=True) + response = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(MISSION_ID, response) method_desc = user_profile_service_pb2.DESCRIPTOR.services_by_name["UserProfileService"].methods_by_name[ "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -428,21 +432,22 @@ def test_get_user_profile_with_unicode_names( thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with Unicode characters in names.""" - profile = user_profile_pb2.UserProfile( + profile = user_profile_messages_pb2.UserProfile( user_id=USER_ID, - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="test@example.com", first_name="José", last_name="François-müller", ) - response = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile) + result = user_profile_messages_pb2.UserProfileResult(profile=profile, success=True) + response = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(MISSION_ID, response) method_desc = user_profile_service_pb2.DESCRIPTOR.services_by_name["UserProfileService"].methods_by_name[ "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -472,13 +477,14 @@ def test_get_user_profile_with_different_locales( for i, locale in enumerate(locales): mission_id = f"missions:mission_{i}" - profile = user_profile_pb2.UserProfile( + profile = user_profile_messages_pb2.UserProfile( user_id=f"users:user_{i}", - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email=f"user{i}@example.com", locale=locale, ) - response = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile) + result = user_profile_messages_pb2.UserProfileResult(profile=profile, success=True) + response = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(mission_id, response) test_client = GrpcUserProfile( @@ -490,7 +496,7 @@ def test_get_user_profile_with_different_locales( test_client.stub = user_profile_service_pb2_grpc.UserProfileServiceStub(test_channel) test_client.exec_grpc_query = types.MethodType(_test_exec_grpc_query, test_client) - future = thread_pool.submit(asyncio.run, test_client.get_user_profile()) + future = thread_pool.submit(asyncio.run, test_client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -511,26 +517,27 @@ def test_get_user_profile_with_zero_credits( thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with zero credits.""" - profile = user_profile_pb2.UserProfile( + profile = user_profile_messages_pb2.UserProfile( user_id=USER_ID, - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="test@example.com", credits=[ - user_profile_pb2.CreditLot( + user_profile_messages_pb2.CreditLot( source="subscription", total=0, remaining=0.0, ) ], ) - response = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile) + result = user_profile_messages_pb2.UserProfileResult(profile=profile, success=True) + response = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(MISSION_ID, response) method_desc = user_profile_service_pb2.DESCRIPTOR.services_by_name["UserProfileService"].methods_by_name[ "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -554,23 +561,24 @@ def test_get_user_profile_with_expired_subscription( thread_pool: futures.ThreadPoolExecutor, ) -> None: """Test retrieving a user profile with expired subscription.""" - profile = user_profile_pb2.UserProfile( + profile = user_profile_messages_pb2.UserProfile( user_id=USER_ID, - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="test@example.com", - subscription=user_profile_pb2.Subscription( + subscription=user_profile_messages_pb2.Subscription( tier="premium", status="expired", ), ) - response = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile) + result = user_profile_messages_pb2.UserProfileResult(profile=profile, success=True) + response = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(MISSION_ID, response) method_desc = user_profile_service_pb2.DESCRIPTOR.services_by_name["UserProfileService"].methods_by_name[ "GetUserProfile" ] - future = thread_pool.submit(asyncio.run, client.get_user_profile()) + future = thread_pool.submit(asyncio.run, client.get()) _, request, rpc = test_channel.take_unary_unary(method_desc) context = FakeContext() @@ -595,26 +603,28 @@ def test_multiple_user_profiles_independence( mission1_id = "missions:mission_1" mission2_id = "missions:mission_2" - profile1 = user_profile_pb2.UserProfile( + profile1 = user_profile_messages_pb2.UserProfile( user_id="users:user_1", - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="user1@example.com", first_name="User", last_name="One", - credits=[user_profile_pb2.CreditLot(source="subscription", total=100, remaining=100.0)], + credits=[user_profile_messages_pb2.CreditLot(source="subscription", total=100, remaining=100.0)], ) - response1 = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile1) + result = user_profile_messages_pb2.UserProfileResult(profile=profile1, success=True) + response1 = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(mission1_id, response1) - profile2 = user_profile_pb2.UserProfile( + profile2 = user_profile_messages_pb2.UserProfile( user_id="users:user_2", - organisation_id=ORGANISATION_ID, + organization_id=organization_id, email="user2@example.com", first_name="User", last_name="Two", - credits=[user_profile_pb2.CreditLot(source="subscription", total=200, remaining=200.0)], + credits=[user_profile_messages_pb2.CreditLot(source="subscription", total=200, remaining=200.0)], ) - response2 = user_profile_pb2.GetUserProfileResponse(success=True, user_profile=profile2) + result = user_profile_messages_pb2.UserProfileResult(profile=profile2, success=True) + response2 = user_profile_dto_pb2.GetUserProfileResponse(result=result) mock_servicer.add_user_profile(mission2_id, response2) method_desc = user_profile_service_pb2.DESCRIPTOR.services_by_name["UserProfileService"].methods_by_name[ @@ -631,7 +641,7 @@ def test_multiple_user_profiles_independence( client1.stub = user_profile_service_pb2_grpc.UserProfileServiceStub(test_channel) client1.exec_grpc_query = types.MethodType(_test_exec_grpc_query, client1) - future1 = thread_pool.submit(asyncio.run, client1.get_user_profile()) + future1 = thread_pool.submit(asyncio.run, client1.get()) _, request1, rpc1 = test_channel.take_unary_unary(method_desc) context1 = FakeContext() resp1 = mock_servicer.GetUserProfile(request1, context1) @@ -648,7 +658,7 @@ def test_multiple_user_profiles_independence( client2.stub = user_profile_service_pb2_grpc.UserProfileServiceStub(test_channel) client2.exec_grpc_query = types.MethodType(_test_exec_grpc_query, client2) - future2 = thread_pool.submit(asyncio.run, client2.get_user_profile()) + future2 = thread_pool.submit(asyncio.run, client2.get()) _, request2, rpc2 = test_channel.take_unary_unary(method_desc) context2 = FakeContext() resp2 = mock_servicer.GetUserProfile(request2, context2) diff --git a/uv.lock b/uv.lock index e7c13aff..bf10e1d6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,18 +1,17 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.15'", "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", - "python_full_version < '3.12'", + "python_full_version < '3.12' or python_full_version >= '3.15'", ] [[package]] name = "agentic-mesh-protocol" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } +version = "0.2.2" +source = { path = "agentic_mesh_protocol-0.2.2-py3-none-any.whl" } dependencies = [ { name = "bump-my-version" }, { name = "googleapis-common-protos" }, @@ -21,23 +20,32 @@ dependencies = [ { name = "protobuf" }, { name = "protovalidate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/e7/8679acaa44b01bbc858275d3fa262e420d61f2a40598200e728924d1d247/agentic_mesh_protocol-0.2.3.tar.gz", hash = "sha256:a542f476d61b4d5acd3f03e7318cbd837ff016be996c1f80ad120222be2d1d95", size = 78843, upload-time = "2026-03-04T16:26:32.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/41/d487e2505531797aba03129187d1b4247499f37a57288a7b2b53d18050c0/agentic_mesh_protocol-0.2.3-py3-none-any.whl", hash = "sha256:ced7c0e4ca2e71ae02cfd8c908c7259165f8d4d98dad85a3e7b86ea0d701b1a9", size = 118882, upload-time = "2026-03-04T16:26:31.258Z" }, + { filename = "agentic_mesh_protocol-0.2.2-py3-none-any.whl", hash = "sha256:f6e7b34ce83ed8f852285a1937548bcd3f58fe6622b0d6ba65153cfe25d94665" }, +] + +[package.metadata] +requires-dist = [ + { name = "bump-my-version", specifier = ">=1.2.6" }, + { name = "googleapis-common-protos", specifier = ">=1.72.0" }, + { name = "grpcio", specifier = ">=1.76.0" }, + { name = "grpcio-tools", specifier = ">=1.76.0" }, + { name = "protobuf", specifier = ">=6.33.4" }, + { name = "protovalidate", specifier = ">=1.0.0" }, ] [[package]] name = "aio-pika" -version = "9.6.1" +version = "9.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiormq" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/8e8513214ed7ceed6bad8b71f7fe196d54ef2277c135bf7960ed715d4227/aio_pika-9.6.1.tar.gz", hash = "sha256:7a130c51a413cfcd04c3322f6a0ab08c38eb9918de1e476f6d34bbf41fc8d2b0", size = 66809, upload-time = "2026-02-23T15:41:52.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/73/8d1020683970de5532b3b01732d75c8bf922a6505fcdad1a9c7c6405242a/aio_pika-9.5.8.tar.gz", hash = "sha256:7c36874115f522bbe7486c46d8dd711a4dbedd67c4e8a8c47efe593d01862c62", size = 47408, upload-time = "2025-11-12T10:37:10.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/24/59a1644995f7a0245588a1761d4b515fc63b7a6038310ead2b07eb44cd8b/aio_pika-9.6.1-py3-none-any.whl", hash = "sha256:0fda50fbbdeb6c5b7399730a2286751074dfe6e52a20119a71aef112d4863fd1", size = 52022, upload-time = "2026-02-23T15:41:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/513971861d845d28160ecb205ae2cfaf618b16918a9cd4e0b832b5360ce7/aio_pika-9.5.8-py3-none-any.whl", hash = "sha256:f4c6cb8a6c5176d00f39fd7431e9702e638449bc6e86d1769ad7548b2a506a8d", size = 54397, upload-time = "2025-11-12T10:37:08.374Z" }, ] [[package]] @@ -171,15 +179,15 @@ wheels = [ [[package]] name = "aiormq" -version = "6.9.3" +version = "6.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pamqp" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/b0/85cb8066acc2df8166f743bdcd793e7179f473a7db746a543bcd40fdac7b/aiormq-6.9.3.tar.gz", hash = "sha256:39f57d85650267aebefca162a523e9e000db02468d4fceccb3c5399f378ddabe", size = 45672, upload-time = "2026-02-22T21:04:49.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/f6/01bc850db6d9b46ae825e3c373f610b0544e725a1159745a6de99ad0d9f1/aiormq-6.9.2.tar.gz", hash = "sha256:d051d46086079934d3a7157f4d8dcb856b77683c2a94aee9faa165efa6a785d3", size = 30554, upload-time = "2025-10-20T10:49:59.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/9b/be86ea5a73010b437bb5f9511d1fcbb828cdf8eddde0c6f2b38006b9ce92/aiormq-6.9.3-py3-none-any.whl", hash = "sha256:fe2e9f7c99d24dde5f7e1ca8a7da2dc5bab9ae5758fd7599b60d34b6b278926e", size = 27939, upload-time = "2026-02-22T21:04:48.208Z" }, + { url = "https://files.pythonhosted.org/packages/52/ec/763b13f148f3760c1562cedb593feaffbae177eeece61af5d0ace7b72a3e/aiormq-6.9.2-py3-none-any.whl", hash = "sha256:ab0f4e88e70f874b0ea344b3c41634d2484b5dc8b17cb6ae0ae7892a172ad003", size = 31829, upload-time = "2025-10-20T10:49:58.547Z" }, ] [[package]] @@ -406,11 +414,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] @@ -831,6 +839,7 @@ dependencies = [ { name = "grpcio-reflection" }, { name = "grpcio-status" }, { name = "pydantic" }, + { name = "surrealdb" }, ] [package.optional-dependencies] @@ -902,7 +911,7 @@ tests = [ [package.metadata] requires-dist = [ - { name = "agentic-mesh-protocol", specifier = "==0.2.3" }, + { name = "agentic-mesh-protocol", path = "agentic_mesh_protocol-0.2.2-py3-none-any.whl" }, { name = "anyio", specifier = "==4.12.1" }, { name = "asyncio-inspector", marker = "extra == 'profiling'", specifier = "==0.1.0" }, { name = "grpcio-health-checking", specifier = "==1.78.0" }, @@ -911,6 +920,7 @@ requires-dist = [ { name = "pydantic", specifier = "==2.12.5" }, { name = "pyinstrument", marker = "extra == 'profiling'", specifier = "==5.1.2" }, { name = "rstream", marker = "extra == 'taskiq'", specifier = "==1.0.0" }, + { name = "surrealdb", specifier = ">=1.0.7" }, { name = "taskiq", extras = ["reload"], marker = "extra == 'taskiq'", specifier = "==0.12.1" }, { name = "taskiq-aio-pika", marker = "extra == 'taskiq'", specifier = "==0.6.0" }, { name = "taskiq-redis", marker = "extra == 'taskiq'", specifier = "==1.2.2" }, @@ -1004,11 +1014,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.0" +version = "3.24.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] [[package]] @@ -1282,63 +1292,63 @@ wheels = [ [[package]] name = "grpcio" -version = "1.78.0" +version = "1.78.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, - { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, - { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, - { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, - { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, - { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, - { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, - { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, - { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, - { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/de/de568532d9907552700f80dcec38219d8d298ad9e71f5e0a095abaf2761e/grpcio-1.78.1.tar.gz", hash = "sha256:27c625532d33ace45d57e775edf1982e183ff8641c72e4e91ef7ba667a149d72", size = 12835760, upload-time = "2026-02-20T01:16:10.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/30/0534b643dafd54824769d6260b89c71d518e4ef8b5ad16b84d1ae9272978/grpcio-1.78.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:4393bef64cf26dc07cd6f18eaa5170ae4eebaafd4418e7e3a59ca9526a6fa30b", size = 5947661, upload-time = "2026-02-20T01:12:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f8/f678566655ab822da0f713789555e7eddca7ef93da99f480c63de3aa94b4/grpcio-1.78.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:917047c19cd120b40aab9a4b8a22e9ce3562f4a1343c0d62b3cd2d5199da3d67", size = 11819948, upload-time = "2026-02-20T01:12:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/a4b4210d946055f4e5a8430f2802202ae8f831b4b00d36d55055c5cf4b6a/grpcio-1.78.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff7de398bb3528d44d17e6913a7cfe639e3b15c65595a71155322df16978c5e1", size = 6519850, upload-time = "2026-02-20T01:12:42.715Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/a1e657a73000a71fa75ec7140ff3a8dc32eb3427560620e477c6a2735527/grpcio-1.78.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:15f6e636d1152667ddb4022b37534c161c8477274edb26a0b65b215dd0a81e97", size = 7198654, upload-time = "2026-02-20T01:12:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/a61c5bdf53c1638e657bb5eebb93c789837820e1fdb965145f05eccc2994/grpcio-1.78.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:27b5cb669603efb7883a882275db88b6b5d6b6c9f0267d5846ba8699b7ace338", size = 6727238, upload-time = "2026-02-20T01:12:48.472Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/aa143d0687801986a29d85788c96089449f36651cd4e2a493737ae0c5be9/grpcio-1.78.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:86edb3966778fa05bfdb333688fde5dc9079f9e2a9aa6a5c42e9564b7656ba04", size = 7300960, upload-time = "2026-02-20T01:12:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/30/d3/53e0f26b46417f28d14b5951fc6a1eff79c08c8a339e967c0a19ec7cf9e9/grpcio-1.78.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:849cc62eb989bc3be5629d4f3acef79be0d0ff15622201ed251a86d17fef6494", size = 8285274, upload-time = "2026-02-20T01:12:53.315Z" }, + { url = "https://files.pythonhosted.org/packages/29/d0/e0e9fd477ce86c07ed1ed1d5c34790f050b6d58bfde77b02b36e23f8b235/grpcio-1.78.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9a00992d6fafe19d648b9ccb4952200c50d8e36d0cce8cf026c56ed3fdc28465", size = 7726620, upload-time = "2026-02-20T01:12:56.498Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/e138a9f7810d196081b2e047c378ca12358c5906d79c42ddec41bb43d528/grpcio-1.78.1-cp310-cp310-win32.whl", hash = "sha256:f8759a1347f3b4f03d9a9d4ce8f9f31ad5e5d0144ba06ccfb1ffaeb0ba4c1e20", size = 4076778, upload-time = "2026-02-20T01:12:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/95/9b02316b85731df0943a635ca6d02f155f673c4f17e60be0c4892a6eb051/grpcio-1.78.1-cp310-cp310-win_amd64.whl", hash = "sha256:e840405a3f1249509892be2399f668c59b9d492068a2cf326d661a8c79e5e747", size = 4798925, upload-time = "2026-02-20T01:13:03.186Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/ad774af3b2c84f49c6d8c4a7bea4c40f02268ea8380630c28777edda463b/grpcio-1.78.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:3a8aa79bc6e004394c0abefd4b034c14affda7b66480085d87f5fbadf43b593b", size = 5951132, upload-time = "2026-02-20T01:13:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/48/9d/ad3c284bedd88c545e20675d98ae904114d8517a71b0efc0901e9166628f/grpcio-1.78.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8e1fcb419da5811deb47b7749b8049f7c62b993ba17822e3c7231e3e0ba65b79", size = 11831052, upload-time = "2026-02-20T01:13:09.604Z" }, + { url = "https://files.pythonhosted.org/packages/6d/08/20d12865e47242d03c3ade9bb2127f5b4aded964f373284cfb357d47c5ac/grpcio-1.78.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b071dccac245c32cd6b1dd96b722283b855881ca0bf1c685cf843185f5d5d51e", size = 6524749, upload-time = "2026-02-20T01:13:21.692Z" }, + { url = "https://files.pythonhosted.org/packages/c6/53/a8b72f52b253ec0cfdf88a13e9236a9d717c332b8aa5f0ba9e4699e94b55/grpcio-1.78.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d6fb962947e4fe321eeef3be1ba5ba49d32dea9233c825fcbade8e858c14aaf4", size = 7198995, upload-time = "2026-02-20T01:13:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/ac769c8ded1bcb26bb119fb472d3374b481b3cf059a0875db9fc77139c17/grpcio-1.78.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6afd191551fd72e632367dfb083e33cd185bf9ead565f2476bba8ab864ae496", size = 6730770, upload-time = "2026-02-20T01:13:26.522Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c3/2275ef4cc5b942314321f77d66179be4097ff484e82ca34bf7baa5b1ddbc/grpcio-1.78.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b2acd83186305c0802dbc4d81ed0ec2f3e8658d7fde97cfba2f78d7372f05b89", size = 7305036, upload-time = "2026-02-20T01:13:30.923Z" }, + { url = "https://files.pythonhosted.org/packages/91/cb/3c2aa99e12cbbfc72c2ed8aa328e6041709d607d668860380e6cd00ba17d/grpcio-1.78.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5380268ab8513445740f1f77bd966d13043d07e2793487e61fd5b5d0935071eb", size = 8288641, upload-time = "2026-02-20T01:13:39.42Z" }, + { url = "https://files.pythonhosted.org/packages/0d/b2/21b89f492260ac645775d9973752ca873acfd0609d6998e9d3065a21ea2f/grpcio-1.78.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:389b77484959bdaad6a2b7dda44d7d1228381dd669a03f5660392aa0e9385b22", size = 7730967, upload-time = "2026-02-20T01:13:41.697Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6b89eddf87fdffb8fa9d37375d44d3a798f4b8116ac363a5f7ca84caa327/grpcio-1.78.1-cp311-cp311-win32.whl", hash = "sha256:9dee66d142f4a8cca36b5b98a38f006419138c3c89e72071747f8fca415a6d8f", size = 4076680, upload-time = "2026-02-20T01:13:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a8/204460b1bc1dff9862e98f56a2d14be3c4171f929f8eaf8c4517174b4270/grpcio-1.78.1-cp311-cp311-win_amd64.whl", hash = "sha256:43b930cf4f9c4a2262bb3e5d5bc40df426a72538b4f98e46f158b7eb112d2d70", size = 4801074, upload-time = "2026-02-20T01:13:46.315Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ed/d2eb9d27fded1a76b2a80eb9aa8b12101da7e41ce2bac0ad3651e88a14ae/grpcio-1.78.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:41e4605c923e0e9a84a2718e4948a53a530172bfaf1a6d1ded16ef9c5849fca2", size = 5913389, upload-time = "2026-02-20T01:13:49.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/1b/40034e9ab010eeb3fa41ec61d8398c6dbf7062f3872c866b8f72700e2522/grpcio-1.78.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:39da1680d260c0c619c3b5fa2dc47480ca24d5704c7a548098bca7de7f5dd17f", size = 11811839, upload-time = "2026-02-20T01:13:51.839Z" }, + { url = "https://files.pythonhosted.org/packages/b4/69/fe16ef2979ea62b8aceb3a3f1e7a8bbb8b717ae2a44b5899d5d426073273/grpcio-1.78.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5d5881d72a09b8336a8f874784a8eeffacde44a7bc1a148bce5a0243a265ef0", size = 6475805, upload-time = "2026-02-20T01:13:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/1e/069e0a9062167db18446917d7c00ae2e91029f96078a072bedc30aaaa8c3/grpcio-1.78.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:888ceb7821acd925b1c90f0cdceaed1386e69cfe25e496e0771f6c35a156132f", size = 7169955, upload-time = "2026-02-20T01:13:59.553Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/44a57e2bb4a755e309ee4e9ed2b85c9af93450b6d3118de7e69410ee05fa/grpcio-1.78.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8942bdfc143b467c264b048862090c4ba9a0223c52ae28c9ae97754361372e42", size = 6690767, upload-time = "2026-02-20T01:14:02.31Z" }, + { url = "https://files.pythonhosted.org/packages/b8/87/21e16345d4c75046d453916166bc72a3309a382c8e97381ec4b8c1a54729/grpcio-1.78.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:716a544969660ed609164aff27b2effd3ff84e54ac81aa4ce77b1607ca917d22", size = 7266846, upload-time = "2026-02-20T01:14:12.974Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d6261983f9ca9ef4d69893765007a9a3211b91d9faf85a2591063df381c7/grpcio-1.78.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d50329b081c223d444751076bb5b389d4f06c2b32d51b31a1e98172e6cecfb9", size = 8253522, upload-time = "2026-02-20T01:14:17.407Z" }, + { url = "https://files.pythonhosted.org/packages/de/7c/4f96a0ff113c5d853a27084d7590cd53fdb05169b596ea9f5f27f17e021e/grpcio-1.78.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e836778c13ff70edada16567e8da0c431e8818eaae85b80d11c1ba5782eccbb", size = 7698070, upload-time = "2026-02-20T01:14:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/17/3c/7b55c0b5af88fbeb3d0c13e25492d3ace41ac9dbd0f5f8f6c0fb613b6706/grpcio-1.78.1-cp312-cp312-win32.whl", hash = "sha256:07eb016ea7444a22bef465cce045512756956433f54450aeaa0b443b8563b9ca", size = 4066474, upload-time = "2026-02-20T01:14:22.602Z" }, + { url = "https://files.pythonhosted.org/packages/5d/17/388c12d298901b0acf10b612b650692bfed60e541672b1d8965acbf2d722/grpcio-1.78.1-cp312-cp312-win_amd64.whl", hash = "sha256:02b82dcd2fa580f5e82b4cf62ecde1b3c7cc9ba27b946421200706a6e5acaf85", size = 4797537, upload-time = "2026-02-20T01:14:25.444Z" }, + { url = "https://files.pythonhosted.org/packages/df/72/754754639cfd16ad04619e1435a518124b2d858e5752225376f9285d4c51/grpcio-1.78.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:2b7ad2981550ce999e25ce3f10c8863f718a352a2fd655068d29ea3fd37b4907", size = 5919437, upload-time = "2026-02-20T01:14:29.403Z" }, + { url = "https://files.pythonhosted.org/packages/5c/84/6267d1266f8bc335d3a8b7ccf981be7de41e3ed8bd3a49e57e588212b437/grpcio-1.78.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:409bfe22220889b9906739910a0ee4c197a967c21b8dd14b4b06dd477f8819ce", size = 11803701, upload-time = "2026-02-20T01:14:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/f3/56/c9098e8b920a54261cd605bbb040de0cde1ca4406102db0aa2c0b11d1fb4/grpcio-1.78.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34b6cb16f4b67eeb5206250dc5b4d5e8e3db939535e58efc330e4c61341554bd", size = 6479416, upload-time = "2026-02-20T01:14:35.926Z" }, + { url = "https://files.pythonhosted.org/packages/86/cf/5d52024371ee62658b7ed72480200524087528844ec1b65265bbcd31c974/grpcio-1.78.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:39d21fd30d38a5afb93f0e2e71e2ec2bd894605fb75d41d5a40060c2f98f8d11", size = 7174087, upload-time = "2026-02-20T01:14:39.98Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/5e59551afad4279e27335a6d60813b8aa3ae7b14fb62cea1d329a459c118/grpcio-1.78.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09fbd4bcaadb6d8604ed1504b0bdf7ac18e48467e83a9d930a70a7fefa27e862", size = 6692881, upload-time = "2026-02-20T01:14:42.466Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/940062de2d14013c02f51b079eb717964d67d46f5d44f22038975c9d9576/grpcio-1.78.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db681513a1bdd879c0b24a5a6a70398da5eaaba0e077a306410dc6008426847a", size = 7269092, upload-time = "2026-02-20T01:14:45.826Z" }, + { url = "https://files.pythonhosted.org/packages/09/87/9db657a4b5f3b15560ec591db950bc75a1a2f9e07832578d7e2b23d1a7bd/grpcio-1.78.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f81816faa426da461e9a597a178832a351d6f1078102590a4b32c77d251b71eb", size = 8252037, upload-time = "2026-02-20T01:14:48.57Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/b980e0265479ec65e26b6e300a39ceac33ecb3f762c2861d4bac990317cf/grpcio-1.78.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffbb760df1cd49e0989f9826b2fd48930700db6846ac171eaff404f3cfbe5c28", size = 7695243, upload-time = "2026-02-20T01:14:51.376Z" }, + { url = "https://files.pythonhosted.org/packages/98/46/5fc42c100ab702fa1ea41a75c890c563c3f96432b4a287d5a6369654f323/grpcio-1.78.1-cp313-cp313-win32.whl", hash = "sha256:1a56bf3ee99af5cf32d469de91bf5de79bdac2e18082b495fc1063ea33f4f2d0", size = 4065329, upload-time = "2026-02-20T01:14:53.952Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/806d60bb6611dfc16cf463d982bd92bd8b6bd5f87dfac66b0a44dfe20995/grpcio-1.78.1-cp313-cp313-win_amd64.whl", hash = "sha256:8991c2add0d8505178ff6c3ae54bd9386279e712be82fa3733c54067aae9eda1", size = 4797637, upload-time = "2026-02-20T01:14:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/96/3a/2d2ec4d2ce2eb9d6a2b862630a0d9d4ff4239ecf1474ecff21442a78612a/grpcio-1.78.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:d101fe49b1e0fb4a7aa36ed0c3821a0f67a5956ef572745452d2cd790d723a3f", size = 5920256, upload-time = "2026-02-20T01:15:00.23Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/dccb7d087a1220ed358753945230c1ddeeed13684b954cb09db6758f1271/grpcio-1.78.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:5ce1855e8cfc217cdf6bcfe0cf046d7cf81ddcc3e6894d6cfd075f87a2d8f460", size = 11813749, upload-time = "2026-02-20T01:15:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/c20e87f87986da9998f30f14776ce27e61f02482a3a030ffe265089342c6/grpcio-1.78.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd26048d066b51f39fe9206e2bcc2cea869a5e5b2d13c8d523f4179193047ebd", size = 6488739, upload-time = "2026-02-20T01:15:14.349Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c2/088bd96e255133d7d87c3eed0d598350d16cde1041bdbe2bb065967aaf91/grpcio-1.78.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b8d7fda614cf2af0f73bbb042f3b7fee2ecd4aea69ec98dbd903590a1083529", size = 7173096, upload-time = "2026-02-20T01:15:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/168db121073a03355ce3552b3b1f790b5ded62deffd7d98c5f642b9d3d81/grpcio-1.78.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:656a5bd142caeb8b1efe1fe0b4434ecc7781f44c97cfc7927f6608627cf178c0", size = 6693861, upload-time = "2026-02-20T01:15:20.911Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d0/90b30ec2d9425215dd56922d85a90babbe6ee7e8256ba77d866b9c0d3aba/grpcio-1.78.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:99550e344482e3c21950c034f74668fccf8a546d50c1ecb4f717543bbdc071ba", size = 7278083, upload-time = "2026-02-20T01:15:23.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fb/73f9ba0b082bcd385d46205095fd9c917754685885b28fce3741e9f54529/grpcio-1.78.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8f27683ca68359bd3f0eb4925824d71e538f84338b3ae337ead2ae43977d7541", size = 8252546, upload-time = "2026-02-20T01:15:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/6a89ea3cb5db6c3d9ed029b0396c49f64328c0cf5d2630ffeed25711920a/grpcio-1.78.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a40515b69ac50792f9b8ead260f194ba2bb3285375b6c40c7ff938f14c3df17d", size = 7696289, upload-time = "2026-02-20T01:15:29.718Z" }, + { url = "https://files.pythonhosted.org/packages/3d/05/63a7495048499ef437b4933d32e59b7f737bd5368ad6fb2479e2bd83bf2c/grpcio-1.78.1-cp314-cp314-win32.whl", hash = "sha256:2c473b54ef1618f4fb85e82ff4994de18143b74efc088b91b5a935a3a45042ba", size = 4142186, upload-time = "2026-02-20T01:15:32.786Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/adfe7e5f701d503be7778291757452e3fab6b19acf51917c79f5d1cf7f8a/grpcio-1.78.1-cp314-cp314-win_amd64.whl", hash = "sha256:e2a6b33d1050dce2c6f563c5caf7f7cbeebf7fba8cde37ffe3803d50526900d1", size = 4932000, upload-time = "2026-02-20T01:15:36.127Z" }, ] [[package]] @@ -1396,65 +1406,65 @@ wheels = [ [[package]] name = "grpcio-tools" -version = "1.78.0" +version = "1.78.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/d1/cbefe328653f746fd319c4377836a25ba64226e41c6a1d7d5cdbc87a459f/grpcio_tools-1.78.0.tar.gz", hash = "sha256:4b0dd86560274316e155d925158276f8564508193088bc43e20d3f5dff956b2b", size = 5393026, upload-time = "2026-02-06T09:59:59.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/70/2118a814a62ab205c905d221064bc09021db83fceeb84764d35c00f0f633/grpcio_tools-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:ea64e38d1caa2b8468b08cb193f5a091d169b6dbfe1c7dac37d746651ab9d84e", size = 2545568, upload-time = "2026-02-06T09:57:30.308Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a9/68134839dd1a00f964185ead103646d6dd6a396b92ed264eaf521431b793/grpcio_tools-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:4003fcd5cbb5d578b06176fd45883a72a8f9203152149b7c680ce28653ad9e3a", size = 5708704, upload-time = "2026-02-06T09:57:33.512Z" }, - { url = "https://files.pythonhosted.org/packages/36/1b/b6135aa9534e22051c53e5b9c0853d18024a41c50aaff464b7b47c1ed379/grpcio_tools-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe6b0081775394c61ec633c9ff5dbc18337100eabb2e946b5c83967fe43b2748", size = 2591905, upload-time = "2026-02-06T09:57:35.338Z" }, - { url = "https://files.pythonhosted.org/packages/41/2b/6380df1390d62b1d18ae18d4d790115abf4997fa29498aa50ba644ecb9d8/grpcio_tools-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7e989ad2cd93db52d7f1a643ecaa156ac55bf0484f1007b485979ce8aef62022", size = 2905271, upload-time = "2026-02-06T09:57:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/3a/07/9b369f37c8f4956b68778c044d57390a8f0f3b1cca590018809e75a4fce2/grpcio_tools-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b874991797e96c41a37e563236c3317ed41b915eff25b292b202d6277d30da85", size = 2656234, upload-time = "2026-02-06T09:57:41.157Z" }, - { url = "https://files.pythonhosted.org/packages/51/61/40eee40e7a54f775a0d4117536532713606b6b177fff5e327f33ad18746e/grpcio_tools-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:daa8c288b728228377aaf758925692fc6068939d9fa32f92ca13dedcbeb41f33", size = 3105770, upload-time = "2026-02-06T09:57:43.373Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ac/81ee4b728e70e8ba66a589f86469925ead02ed6f8973434e4a52e3576148/grpcio_tools-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:87e648759b06133199f4bc0c0053e3819f4ec3b900dc399e1097b6065db998b5", size = 3654896, upload-time = "2026-02-06T09:57:45.402Z" }, - { url = "https://files.pythonhosted.org/packages/be/b9/facb3430ee427c800bb1e39588c85685677ea649491d6e0874bd9f3a1c0e/grpcio_tools-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f3d3ced52bfe39eba3d24f5a8fab4e12d071959384861b41f0c52ca5399d6920", size = 3322529, upload-time = "2026-02-06T09:57:47.292Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/d7a011df9abfed8c30f0d2077b0562a6e3edc57cb3e5514718e2a81f370a/grpcio_tools-1.78.0-cp310-cp310-win32.whl", hash = "sha256:4bb6ed690d417b821808796221bde079377dff98fdc850ac157ad2f26cda7a36", size = 993518, upload-time = "2026-02-06T09:57:48.836Z" }, - { url = "https://files.pythonhosted.org/packages/c8/5e/f7f60c3ae2281c6b438c3a8455f4a5d5d2e677cf20207864cbee3763da22/grpcio_tools-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c676d8342fd53bd85a5d5f0d070cd785f93bc040510014708ede6fcb32fada1", size = 1158505, upload-time = "2026-02-06T09:57:50.633Z" }, - { url = "https://files.pythonhosted.org/packages/75/78/280184d19242ed6762bf453c47a70b869b3c5c72a24dc5bf2bf43909faa3/grpcio_tools-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:6a8b8b7b49f319d29dbcf507f62984fa382d1d10437d75c3f26db5f09c4ac0af", size = 2545904, upload-time = "2026-02-06T09:57:52.769Z" }, - { url = "https://files.pythonhosted.org/packages/5b/51/3c46dea5113f68fe879961cae62d34bb7a3c308a774301b45d614952ee98/grpcio_tools-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d62cf3b68372b0c6d722a6165db41b976869811abeabc19c8522182978d8db10", size = 5709078, upload-time = "2026-02-06T09:57:56.389Z" }, - { url = "https://files.pythonhosted.org/packages/e0/2c/dc1ae9ec53182c96d56dfcbf3bcd3e55a8952ad508b188c75bf5fc8993d4/grpcio_tools-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa9056742efeaf89d5fe14198af71e5cbc4fbf155d547b89507e19d6025906c6", size = 2591744, upload-time = "2026-02-06T09:57:58.341Z" }, - { url = "https://files.pythonhosted.org/packages/04/63/9b53fc9a9151dd24386785171a4191ee7cb5afb4d983b6a6a87408f41b28/grpcio_tools-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e3191af125dcb705aa6bc3856ba81ba99b94121c1b6ebee152e66ea084672831", size = 2905113, upload-time = "2026-02-06T09:58:00.38Z" }, - { url = "https://files.pythonhosted.org/packages/96/b2/0ad8d789f3a2a00893131c140865605fa91671a6e6fcf9da659e1fabba10/grpcio_tools-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:283239ddbb67ae83fac111c61b25d8527a1dbd355b377cbc8383b79f1329944d", size = 2656436, upload-time = "2026-02-06T09:58:03.038Z" }, - { url = "https://files.pythonhosted.org/packages/09/4d/580f47ce2fc61b093ade747b378595f51b4f59972dd39949f7444b464a03/grpcio_tools-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac977508c0db15301ef36d6c79769ec1a6cc4e3bc75735afca7fe7e360cead3a", size = 3106128, upload-time = "2026-02-06T09:58:05.064Z" }, - { url = "https://files.pythonhosted.org/packages/c9/29/d83b2d89f8d10e438bad36b1eb29356510fb97e81e6a608b22ae1890e8e6/grpcio_tools-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4ff605e25652a0bd13aa8a73a09bc48669c68170902f5d2bf1468a57d5e78771", size = 3654953, upload-time = "2026-02-06T09:58:07.15Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/917ce85633311e54fefd7e6eb1224fb780ef317a4d092766f5630c3fc419/grpcio_tools-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0197d7b561c79be78ab93d0fe2836c8def470683df594bae3ac89dd8e5c821b2", size = 3322630, upload-time = "2026-02-06T09:58:10.305Z" }, - { url = "https://files.pythonhosted.org/packages/b2/55/3fbf6b26ab46fc79e1e6f7f4e0993cf540263dad639290299fad374a0829/grpcio_tools-1.78.0-cp311-cp311-win32.whl", hash = "sha256:28f71f591f7f39555863ced84fcc209cbf4454e85ef957232f43271ee99af577", size = 993804, upload-time = "2026-02-06T09:58:13.698Z" }, - { url = "https://files.pythonhosted.org/packages/73/86/4affe006d9e1e9e1c6653d6aafe2f8b9188acb2b563cd8ed3a2c7c0e8aec/grpcio_tools-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a6de495dabf86a3b40b9a7492994e1232b077af9d63080811838b781abbe4e8", size = 1158566, upload-time = "2026-02-06T09:58:15.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ae/5b1fa5dd8d560a6925aa52de0de8731d319f121c276e35b9b2af7cc220a2/grpcio_tools-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:9eb122da57d4cad7d339fc75483116f0113af99e8d2c67f3ef9cae7501d806e4", size = 2546823, upload-time = "2026-02-06T09:58:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ed/d33ccf7fa701512efea7e7e23333b748848a123e9d3bbafde4e126784546/grpcio_tools-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d0c501b8249940b886420e6935045c44cb818fa6f265f4c2b97d5cff9cb5e796", size = 5706776, upload-time = "2026-02-06T09:58:20.944Z" }, - { url = "https://files.pythonhosted.org/packages/c6/69/4285583f40b37af28277fc6b867d636e3b10e1b6a7ebd29391a856e1279b/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:77e5aa2d2a7268d55b1b113f958264681ef1994c970f69d48db7d4683d040f57", size = 2593972, upload-time = "2026-02-06T09:58:23.29Z" }, - { url = "https://files.pythonhosted.org/packages/d7/eb/ecc1885bd6b3147f0a1b7dff5565cab72f01c8f8aa458f682a1c77a9fb08/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e3c0b0e6ba5275322ba29a97bf890565a55f129f99a21b121145e9e93a22525", size = 2905531, upload-time = "2026-02-06T09:58:25.406Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a9/511d0040ced66960ca10ba0f082d6b2d2ee6dd61837b1709636fdd8e23b4/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975d4cb48694e20ebd78e1643e5f1cd94cdb6a3d38e677a8e84ae43665aa4790", size = 2656909, upload-time = "2026-02-06T09:58:28.022Z" }, - { url = "https://files.pythonhosted.org/packages/06/a3/3d2c707e7dee8df842c96fbb24feb2747e506e39f4a81b661def7fed107c/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:553ff18c5d52807dedecf25045ae70bad7a3dbba0b27a9a3cdd9bcf0a1b7baec", size = 3109778, upload-time = "2026-02-06T09:58:30.091Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4b/646811ba241bf05da1f0dc6f25764f1c837f78f75b4485a4210c84b79eae/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8c7f5e4af5a84d2e96c862b1a65e958a538237e268d5f8203a3a784340975b51", size = 3658763, upload-time = "2026-02-06T09:58:32.875Z" }, - { url = "https://files.pythonhosted.org/packages/45/de/0a5ef3b3e79d1011375f5580dfee3a9c1ccb96c5f5d1c74c8cee777a2483/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96183e2b44afc3f9a761e9d0f985c3b44e03e8bb98e626241a6cbfb3b6f7e88f", size = 3325116, upload-time = "2026-02-06T09:58:34.894Z" }, - { url = "https://files.pythonhosted.org/packages/95/d2/6391b241ad571bc3e71d63f957c0b1860f0c47932d03c7f300028880f9b8/grpcio_tools-1.78.0-cp312-cp312-win32.whl", hash = "sha256:2250e8424c565a88573f7dc10659a0b92802e68c2a1d57e41872c9b88ccea7a6", size = 993493, upload-time = "2026-02-06T09:58:37.242Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8f/7d0d3a39ecad76ccc136be28274daa660569b244fa7d7d0bbb24d68e5ece/grpcio_tools-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:217d1fa29de14d9c567d616ead7cb0fef33cde36010edff5a9390b00d52e5094", size = 1158423, upload-time = "2026-02-06T09:58:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/ce/17311fb77530420e2f441e916b347515133e83d21cd6cc77be04ce093d5b/grpcio_tools-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2d6de1cc23bdc1baafc23e201b1e48c617b8c1418b4d8e34cebf72141676e5fb", size = 2546284, upload-time = "2026-02-06T09:58:43.073Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/79e101483115f0e78223397daef71751b75eba7e92a32060c10aae11ca64/grpcio_tools-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2afeaad88040894c76656202ff832cb151bceb05c0e6907e539d129188b1e456", size = 5705653, upload-time = "2026-02-06T09:58:45.533Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a7/52fa3ccb39ceeee6adc010056eadfbca8198651c113e418dafebbdf2b306/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33cc593735c93c03d63efe7a8ba25f3c66f16c52f0651910712490244facad72", size = 2592788, upload-time = "2026-02-06T09:58:48.918Z" }, - { url = "https://files.pythonhosted.org/packages/68/08/682ff6bb548225513d73dc9403742d8975439d7469c673bc534b9bbc83a7/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2921d7989c4d83b71f03130ab415fa4d66e6693b8b8a1fcbb7a1c67cff19b812", size = 2905157, upload-time = "2026-02-06T09:58:51.478Z" }, - { url = "https://files.pythonhosted.org/packages/b2/66/264f3836a96423b7018e5ada79d62576a6401f6da4e1f4975b18b2be1265/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6a0df438e82c804c7b95e3f311c97c2f876dcc36376488d5b736b7bcf5a9b45", size = 2656166, upload-time = "2026-02-06T09:58:54.117Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/f108276611522e03e98386b668cc7e575eff6952f2db9caa15b2a3b3e883/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9c6070a9500798225191ef25d0055a15d2c01c9c8f2ee7b681fffa99c98c822", size = 3109110, upload-time = "2026-02-06T09:58:56.891Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c7/cf048dbcd64b3396b3c860a2ffbcc67a8f8c87e736aaa74c2e505a7eee4c/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:394e8b57d85370a62e5b0a4d64c96fcf7568345c345d8590c821814d227ecf1d", size = 3657863, upload-time = "2026-02-06T09:58:59.176Z" }, - { url = "https://files.pythonhosted.org/packages/b6/37/e2736912c8fda57e2e57a66ea5e0bc8eb9a5fb7ded00e866ad22d50afb08/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3ef700293ab375e111a2909d87434ed0a0b086adf0ce67a8d9cf12ea7765e63", size = 3324748, upload-time = "2026-02-06T09:59:01.242Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/726abc75bb5bfc2841e88ea05896e42f51ca7c30cb56da5c5b63058b3867/grpcio_tools-1.78.0-cp313-cp313-win32.whl", hash = "sha256:6993b960fec43a8d840ee5dc20247ef206c1a19587ea49fe5e6cc3d2a09c1585", size = 993074, upload-time = "2026-02-06T09:59:03.085Z" }, - { url = "https://files.pythonhosted.org/packages/c5/68/91b400bb360faf9b177ffb5540ec1c4d06ca923691ddf0f79e2c9683f4da/grpcio_tools-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:275ce3c2978842a8cf9dd88dce954e836e590cf7029649ad5d1145b779039ed5", size = 1158185, upload-time = "2026-02-06T09:59:05.036Z" }, - { url = "https://files.pythonhosted.org/packages/cf/5e/278f3831c8d56bae02e3acc570465648eccf0a6bbedcb1733789ac966803/grpcio_tools-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b080d0d072e6032708a3a91731b808074d7ab02ca8fb9847b6a011fdce64cd9", size = 2546270, upload-time = "2026-02-06T09:59:07.426Z" }, - { url = "https://files.pythonhosted.org/packages/a3/d9/68582f2952b914b60dddc18a2e3f9c6f09af9372b6f6120d6cf3ec7f8b4e/grpcio_tools-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8c0ad8f8f133145cd7008b49cb611a5c6a9d89ab276c28afa17050516e801f79", size = 5705731, upload-time = "2026-02-06T09:59:09.856Z" }, - { url = "https://files.pythonhosted.org/packages/70/68/feb0f9a48818ee1df1e8b644069379a1e6ef5447b9b347c24e96fd258e5d/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f8ea092a7de74c6359335d36f0674d939a3c7e1a550f4c2c9e80e0226de8fe4", size = 2593896, upload-time = "2026-02-06T09:59:12.23Z" }, - { url = "https://files.pythonhosted.org/packages/1f/08/a430d8d06e1b8d33f3e48d3f0cc28236723af2f35e37bd5c8db05df6c3aa/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:da422985e0cac822b41822f43429c19ecb27c81ffe3126d0b74e77edec452608", size = 2905298, upload-time = "2026-02-06T09:59:14.458Z" }, - { url = "https://files.pythonhosted.org/packages/71/0a/348c36a3eae101ca0c090c9c3bc96f2179adf59ee0c9262d11cdc7bfe7db/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fab1faa3fbcb246263e68da7a8177d73772283f9db063fb8008517480888d26", size = 2656186, upload-time = "2026-02-06T09:59:16.949Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3f/18219f331536fad4af6207ade04142292faa77b5cb4f4463787988963df8/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dd9c094f73f734becae3f20f27d4944d3cd8fb68db7338ee6c58e62fc5c3d99f", size = 3109859, upload-time = "2026-02-06T09:59:19.202Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d9/341ea20a44c8e5a3a18acc820b65014c2e3ea5b4f32a53d14864bcd236bc/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2ed51ce6b833068f6c580b73193fc2ec16468e6bc18354bc2f83a58721195a58", size = 3657915, upload-time = "2026-02-06T09:59:21.839Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f4/5978b0f91611a64371424c109dd0027b247e5b39260abad2eaee66b6aa37/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05803a5cdafe77c8bdf36aa660ad7a6a1d9e49bc59ce45c1bade2a4698826599", size = 3324724, upload-time = "2026-02-06T09:59:24.402Z" }, - { url = "https://files.pythonhosted.org/packages/b2/80/96a324dba99cfbd20e291baf0b0ae719dbb62b76178c5ce6c788e7331cb1/grpcio_tools-1.78.0-cp314-cp314-win32.whl", hash = "sha256:f7c722e9ce6f11149ac5bddd5056e70aaccfd8168e74e9d34d8b8b588c3f5c7c", size = 1015505, upload-time = "2026-02-06T09:59:26.3Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d1/909e6a05bfd44d46327dc4b8a78beb2bae4fb245ffab2772e350081aaf7e/grpcio_tools-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d58ade518b546120ec8f0a8e006fc8076ae5df151250ebd7e82e9b5e152c229", size = 1190196, upload-time = "2026-02-06T09:59:28.359Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c9/e5/311efa9278a451291e317286babf3f69b1479f8e6fd244836e3803e4b81d/grpcio_tools-1.78.1.tar.gz", hash = "sha256:f47b746b06a940954b9aa86b1824aa4874f068a7ec2d4b407980d202c86a691a", size = 5392610, upload-time = "2026-02-20T01:19:44.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/cc/4c6010153ec59ea8833375bc0cce0150b31a8fece551867cc4dfd57f799c/grpcio_tools-1.78.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:ec86147000d713bcf5116350607b16b488432fcae89e7fbb6ac4d388c241273b", size = 2545568, upload-time = "2026-02-20T01:16:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/e2/18/9448e26f026ddad65e84702e44db558c58fa5f3a8ee85dffb68565b1f964/grpcio_tools-1.78.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ecb698ba221b279356590d65e456d2a3ba63b1668515c85c5a340bf98399acb7", size = 5708709, upload-time = "2026-02-20T01:16:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/862d3defacf28e2d95856a2242c6524fb337470cda8e03bc162a2bab10a3/grpcio_tools-1.78.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:77b8f61e5b6b774521875595d5f978dbd534086bc39205126345c7459cf18a44", size = 2591906, upload-time = "2026-02-20T01:16:34.156Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/e6fb73def5661233a0ab3cfd398894e5d2be2beb6d653d0a3364c762e2b2/grpcio_tools-1.78.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6080c1541487071c6e2763be5ffee452139a919dc5fc9e0eaeca9737af913337", size = 2905270, upload-time = "2026-02-20T01:16:36.76Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/a9f09051aa7490007aa49508b7bb5f865e5bbfa03541e606ba5be8f915f5/grpcio_tools-1.78.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:086cda613dc3a5b58ebd0852273fa76498d61e5296710654d66861309ea30faa", size = 2656237, upload-time = "2026-02-20T01:16:41.685Z" }, + { url = "https://files.pythonhosted.org/packages/37/86/a9af580c764976451cb607ca56ffb7e02e2995c4c5ea80e3275a54705fb1/grpcio_tools-1.78.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:35668bc67bb5600d3f72e9cfbbe15a2ad2f616013b0598877a06396e7de3fa2f", size = 3105766, upload-time = "2026-02-20T01:16:44.893Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8a/1a6bd95d5e581191bf4e3865ae9a2c87f493367721ebfc1d5869ddf007be/grpcio_tools-1.78.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:63d578f37a6ccad7f61b1da29b219005874c097664a78967f8b60637f6f3f567", size = 3654897, upload-time = "2026-02-20T01:16:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/26/48/685979f30f9f55a455c2da90bb2898a65657be13062d1708097a4f29f4e9/grpcio_tools-1.78.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c05507d90035e8c0b9617d2ff5c888b7e93e47c111e7880d8a2d190ca5734622", size = 3322530, upload-time = "2026-02-20T01:16:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/c289037c267bf58e6839936880ca552b0b1b569ea411e65711884c5c3b16/grpcio_tools-1.78.1-cp310-cp310-win32.whl", hash = "sha256:c7a33d981d33b54183e2fa872a4abea632396ef824ca60c268ce50e2fdb9d930", size = 993638, upload-time = "2026-02-20T01:16:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/ea/66/b319aabc3907d6947d9bd502b87842b14c470ab62fd7beadc053cd3ad17a/grpcio_tools-1.78.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e8cdf8f75d24a70511b3db9e4e09cd7a4420ff1a3707e30cefc08f4be189e1f", size = 1158503, upload-time = "2026-02-20T01:16:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/47/c3/b598440ea531f7abdb9c1c5298919e13f4442f0289900dd9ef6667ab72b9/grpcio_tools-1.78.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:ec4483749c7174c301a554191f6a9b28e2388636736a21886fe20025137cdaa5", size = 2545903, upload-time = "2026-02-20T01:16:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7f/c81ca206047e600ced7c3147f84c894d1ab7eb07642d0a1a4d8511bca7d7/grpcio_tools-1.78.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a81b30b0981cc64853bf28daa4d45f2ce8e4da47d831186a509c05660f23b133", size = 5709065, upload-time = "2026-02-20T01:17:00.054Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/9b4a601afabf017dadff24d066d0dc6cedfab1e4cdc8e52bcca08291e7e0/grpcio_tools-1.78.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6406f04b93e48ae3b4dca8f9f312f345265502dc54408056796813c1877f98a", size = 2591744, upload-time = "2026-02-20T01:17:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/46/be/e20b48c5fbbf7c279e3998a62d086f35e0c6efb0d7a9a3ab2966a235b89d/grpcio_tools-1.78.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f46fa1430958fe93082d361711e261a482d5a505a9928bc28f7df3fb432d7203", size = 2905111, upload-time = "2026-02-20T01:17:04.659Z" }, + { url = "https://files.pythonhosted.org/packages/68/6b/72b97c60767bc5b256e5e4ba5c4a01dd27dc925c983d85fa8c9ca428f0c9/grpcio_tools-1.78.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a5fbe7d04212248a94acfea86460f1e249f0e42b636de4e71ad518aaf7b24cc9", size = 2656443, upload-time = "2026-02-20T01:17:07.154Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/1023bf1bfa1eaa3b98582f4ba2fcae7153b5b3382b2b3d5b00b47b6e0f57/grpcio_tools-1.78.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e886a3f3284fbff5b4a5c0299427b42df1e1ad6ec9c88c41cfe94557ac191a34", size = 3106129, upload-time = "2026-02-20T01:17:09.385Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/2c1d64e9efcd0a55b0fc2e6426e65c33edecc590510e67f623e8bd92bfdf/grpcio_tools-1.78.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e1e19c3cb8c4bbfcc20c74b6ef50bd2fb18f82593e65c5b031a92f6794ab9a6d", size = 3654955, upload-time = "2026-02-20T01:17:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/41/76/cc00f693a085e6ddc2477d6dc59ad3e0b0a2f8797ff3703b45e25ddca387/grpcio_tools-1.78.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b0abde2cd28a5925da36776977064e0fe9be667a96ea454acad1eabc3eb7ec48", size = 3322628, upload-time = "2026-02-20T01:17:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8f/4efdc5a359ae37b079008eda939cf063ab51608a9767f808c672fc542780/grpcio_tools-1.78.1-cp311-cp311-win32.whl", hash = "sha256:a62857bdd681469f7ea603078187399aa8bd8cd7bdeeb603497c993a06d0bb8d", size = 993780, upload-time = "2026-02-20T01:17:20.463Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/f6925bfdab52eed2dbaf54ca656de6401e880d079800df53d59cf89a2098/grpcio_tools-1.78.1-cp311-cp311-win_amd64.whl", hash = "sha256:e33de930d02e16d28a2e06d2a629cd5be18c0f386e8bc6c483b073f8898c283c", size = 1158625, upload-time = "2026-02-20T01:17:22.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/74/928b78c079cf84436e6d6abd52879178c00c1d0dd9bcaf294c3601db8c73/grpcio_tools-1.78.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:2fd5b9ba19849afb511f05f9eaf621aaf21d8582b06d23179b31fb72f2b0add1", size = 2546822, upload-time = "2026-02-20T01:17:24.899Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/22b29fa672535b525070d5b665b064903e4dddce694f036fae115978245f/grpcio_tools-1.78.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3e2148b0b15dea87d2fea17d1eda3ae0cdc6dd378fe75903f17515cbb6e5f4a3", size = 5706796, upload-time = "2026-02-20T01:17:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ac/8ab479655b52bb8a1f55fd73df0b7b9fe6e5470775a3432b6265ff2782df/grpcio_tools-1.78.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58714482282ba4f6ebe550a43284b3383761e7bf1c1cafa009740d4b20cfc5fd", size = 2593971, upload-time = "2026-02-20T01:17:32.843Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/f7b48b112ef8b457e2a30d13cb357947bbb98635b016db6d4e1885c5160e/grpcio_tools-1.78.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3edc65d8d547e2c3e90937896bce58f1a4187b45a5ac2d97c84d0501c917c6e7", size = 2905532, upload-time = "2026-02-20T01:17:35.512Z" }, + { url = "https://files.pythonhosted.org/packages/94/3c/c74185dbbaa5930ac124121e9546f7aec54790ad2b2a352ae13606c2d0a5/grpcio_tools-1.78.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa5720e07b81e82107c33f1951572f4371b668933da110418146e8fe51813ec", size = 2656908, upload-time = "2026-02-20T01:17:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/a5dfcdec7d2eabcd1aafa20dd1c558fe34907e86672a39afdea1ed48556f/grpcio_tools-1.78.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d665399893f79dfce1018143602b1e53cc6434cb919b141ad5ce9d09d25b6c88", size = 3109782, upload-time = "2026-02-20T01:17:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/c3a9a79f2da4ae99cc1290f2a90e5d798525e9556d6b2e7f7090d4a05271/grpcio_tools-1.78.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3ab437967bd61034b278ca1043a5f2f70ab3a8b45f2531b4295ffc7da27893c9", size = 3658761, upload-time = "2026-02-20T01:17:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/656a38e78cebed3b98bb737630faa30d0f22915112166b892e89a843e08f/grpcio_tools-1.78.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:63c91efb22a6977111bbde16f58e393ab75f1f4ff95850abc24fd279402a02f7", size = 3325115, upload-time = "2026-02-20T01:17:46.089Z" }, + { url = "https://files.pythonhosted.org/packages/00/c0/8e3444d127c243170a7dc732d6cbd009c3de78a86002f7abdc317ce7f828/grpcio_tools-1.78.1-cp312-cp312-win32.whl", hash = "sha256:7ecc57c2a82a7f67d07c1491eea39aec9660306a8b67b7b0116ade52c3466297", size = 993478, upload-time = "2026-02-20T01:17:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/a5/cc/e2211fe54ae29b31ed20154e002184546ed08800105aeb8692898f7b4a6f/grpcio_tools-1.78.1-cp312-cp312-win_amd64.whl", hash = "sha256:7e465bf6e49c8d3905997b079d4cab233cd1e0ad558aa3b93ce074172ad75fa1", size = 1158466, upload-time = "2026-02-20T01:17:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/09/44/b8371d238bcb6141b178f91d65477e5aec9fe6d3f7c245e581bdb73e6330/grpcio_tools-1.78.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3ad2cfae254f965776e296635d0ef96bdb2e6fde54c3d8e0f1ed98161ec00a8f", size = 2546284, upload-time = "2026-02-20T01:17:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/db6f388ad57d23ec7e3fdc4a1d12495cadc022df8a6138b827dbc6ad1b79/grpcio_tools-1.78.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b5d4c75fa44d560e694b65b19df3d7e73d89c2bf9e2d7b672a9e650f40ca33df", size = 5705688, upload-time = "2026-02-20T01:17:56.279Z" }, + { url = "https://files.pythonhosted.org/packages/16/44/9b2dc99c0fdc8f83ffa72e51fc52f0ad5015fc6c0dc733ba0e0eeb289916/grpcio_tools-1.78.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:63e87dd399a4071c0cfdf131cf382a7c3859f2bee9cff8ec996dd8dea3e3afbb", size = 2592788, upload-time = "2026-02-20T01:17:58.729Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f2/885039af8fcda73eca2d244fcd295381c682919b39f2078453e6cb002879/grpcio_tools-1.78.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08704fd6df74dd95c28a2c095f59e10aec61abe64e2c44f1109d725f728688ba", size = 2905157, upload-time = "2026-02-20T01:18:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/98/a4/859e99a1a728367bbe4d5671e92c984536077e2690fef5637c5e66434b5b/grpcio_tools-1.78.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e53faada7c186ae5a46b236a4961284c45f9eb069888c651021346f9360d58e0", size = 2656161, upload-time = "2026-02-20T01:18:03.386Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6e/2f8b6b6e06d5728c571ec4b702a3a249743816b771b78f2dc79be67dea33/grpcio_tools-1.78.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99479dfa64faa8ed887df22a1489e6bd4027e38efdad7de9fdc6038e67569f0a", size = 3109110, upload-time = "2026-02-20T01:18:08.877Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f5/1cf1b232a996b8eb560adb4a75d468fa0badf804ce182a0ce7b03b796299/grpcio_tools-1.78.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e0335ba2e6b903b9156151a49d03e74d2876259d5233ac97de53b4c847a56000", size = 3657864, upload-time = "2026-02-20T01:18:18.75Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/0714adbb17bd12661fb7cd247991a175069de9269bd506cff3cd9638f6c4/grpcio_tools-1.78.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d54640c46d496ed9367caaa36a5742adca9b215ea06cf6714dcf1aa190a43b6d", size = 3324749, upload-time = "2026-02-20T01:18:21.394Z" }, + { url = "https://files.pythonhosted.org/packages/3a/15/bb6ac754ce74980ea78bdd428b8d5fdeda77e04a674388aac81c885595ea/grpcio_tools-1.78.1-cp313-cp313-win32.whl", hash = "sha256:df604903f86adae37eb90f4168db13090f723b3602bac89519aff451aea46ea3", size = 993050, upload-time = "2026-02-20T01:18:24.132Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/9d75e3ad324b11de988a7534d22c066e0da7cffc5656f973993e719efab1/grpcio_tools-1.78.1-cp313-cp313-win_amd64.whl", hash = "sha256:7f4469a91556442330aad0710ffc16a853681e1aa7c0752b2db2e8255c872897", size = 1158153, upload-time = "2026-02-20T01:18:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5b/76d7969539159a22dfa20c23c3885ce9024a12d88e23d76063a1e1df566d/grpcio_tools-1.78.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:11c6a338c227e5aab76954f35959682d59c432a5b6d7db053fa1a99c7124bbde", size = 2546269, upload-time = "2026-02-20T01:18:30.673Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8a/a1d180e7165bbdd30d109a448c95d6077eaa9afe40a2ed159f40bec64ce3/grpcio_tools-1.78.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:090aeaa053f728539d0f84658bb5d88411a913cbcc49e990b5a80acd3c46dc94", size = 5705752, upload-time = "2026-02-20T01:18:33.396Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/067b95424eee7cb980a2237c3ecd23935ea742b17acf4411064a727ec9b0/grpcio_tools-1.78.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:203347a50b00e6a1793c35af437a39449b247b9461a9f1f9b9baf954b4255cd8", size = 2593895, upload-time = "2026-02-20T01:18:36.746Z" }, + { url = "https://files.pythonhosted.org/packages/01/1f/6edd882a7c47f74321aeec98ef20b7c54c4fa61c81bb08039b14c1777de2/grpcio_tools-1.78.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7c3cef48b10cccfc039b5ae054d7ad8d7b907ff03a283b606b3999ce3843b5a5", size = 2905296, upload-time = "2026-02-20T01:18:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/15/2ecacd23670fd8bc945fa1a3ae5ad0916c95d9803ceda0b7427d9dfc4ee0/grpcio_tools-1.78.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abb2aee19b91d619670a3598faaa8036b31dd96708ab82d8fb990da4b5c3fc01", size = 2656183, upload-time = "2026-02-20T01:18:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/60/36/ec4b0172f803f7add82bcc16346b47a80ca983539dc5bf779da1d44f3b4a/grpcio_tools-1.78.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b6307ce936cd5f7714bba75e8b7c71f4e6a4da625b907960227568022ee812fa", size = 3109860, upload-time = "2026-02-20T01:18:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/79/85/6fb37b10667764505f9bc6baab9bccaaa0777bfe07aa786f9e1d4f482253/grpcio_tools-1.78.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:40aad3da94bf261792ff998084117f6ce092b7b137dcea257628def834b91e96", size = 3657914, upload-time = "2026-02-20T01:18:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/42/11/da86c93b0d00f5180d283740c89aa998f955c7389ff268128b99c5bebdb9/grpcio_tools-1.78.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36dbd00415376a3db03cd57a8063dfb5506c3ec69737945488f6c28a3e8b5cf1", size = 3324719, upload-time = "2026-02-20T01:19:01.55Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b6/6a00609300fbfe2163183522005911f4676bc80db374d55c2a9d9e70997e/grpcio_tools-1.78.1-cp314-cp314-win32.whl", hash = "sha256:6d284037ff456842324fa12b0a6455fce0b3ab92f218677b34c33cf4787a54c4", size = 1015537, upload-time = "2026-02-20T01:19:04.289Z" }, + { url = "https://files.pythonhosted.org/packages/05/76/ef3d2f5a86da2b3a2abcef7141bc4d2d8d119b0da389029811a4507b499b/grpcio_tools-1.78.1-cp314-cp314-win_amd64.whl", hash = "sha256:acb9849783dc7cf0e7359cbd60c6bf3154008bf9aeff12c696ec7289599eb3a8", size = 1190123, upload-time = "2026-02-20T01:19:06.831Z" }, ] [[package]] @@ -1552,11 +1562,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.17" +version = "2.6.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, ] [[package]] @@ -1573,7 +1583,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp", marker = "python_full_version < '3.12' or python_full_version >= '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -3032,16 +3042,16 @@ wheels = [ [[package]] name = "protovalidate" -version = "1.1.2" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cel-python" }, { name = "google-re2" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/9e/38742fe4006fb6d9101fd416e9bba4213984b7aaa2ae1a99721d2f8770a9/protovalidate-1.1.2.tar.gz", hash = "sha256:33d13b49e56e87c2ef4c8f0cbce4776288141a3c79a1e48fb172444bf4de47bb", size = 222185, upload-time = "2026-03-02T15:15:13.795Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/75/55b0e85e8c4247e754015a65a291990f23ae738a5d20992a251637f1cdf8/protovalidate-1.1.1.tar.gz", hash = "sha256:41bc38482bccd75b88294532d44f5b74e6b576359d1302bcadfc231f7622bd23", size = 226213, upload-time = "2026-02-02T13:57:24.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/6d/d199a67b9580d45939419c9f2c7c9d6a898b611a908b12606d997c6ab8be/protovalidate-1.1.2-py3-none-any.whl", hash = "sha256:21d4a5ad68a0d59222411af3c53c6f63d1318381e31c069143811e193f6fcf67", size = 29655, upload-time = "2026-03-02T15:15:12.123Z" }, + { url = "https://files.pythonhosted.org/packages/9b/50/36201b523b6f3eba276b8918f081c698317a62da1da9269f6417b4abd216/protovalidate-1.1.1-py3-none-any.whl", hash = "sha256:1c9daee765824011071ece81924df07a325931790075a3a00c04de7ed7ea9d13", size = 29589, upload-time = "2026-02-02T13:57:22.578Z" }, ] [[package]] @@ -3455,26 +3465,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-discovery" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, -] - [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] [[package]] @@ -3590,14 +3587,14 @@ wheels = [ [[package]] name = "redis" -version = "7.2.1" +version = "7.2.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/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, + { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, ] [[package]] @@ -3834,6 +3831,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] +[[package]] +name = "surrealdb" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/ad/6f7e69bddb77a7b8deb0874652a8e9a4a15e15736d09f13911f1a9490294/surrealdb-1.0.8.tar.gz", hash = "sha256:14a9b2e24b8a2fbe15b6894617a2c2aababaf02e7fb95bd755ab9182b40c92c6", size = 291033, upload-time = "2026-01-07T18:18:40.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/a5/682e642b0b161b49a43aec930604bbc9367dff6ebe7e53dd7768ed25195d/surrealdb-1.0.8-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:afc95b38d915ac7cb9adafc32d6e9b5a9548470095dad67efe626dd3b7bdbfc7", size = 5130558, upload-time = "2026-01-07T18:18:32.986Z" }, + { url = "https://files.pythonhosted.org/packages/73/94/8a0ef6934190e2aef75a3862246dca50b747c60fe87da79ef07ecea085ea/surrealdb-1.0.8-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4052ea81bbb999bc4e48a38bbd852f89d52840bcc52573dbfa009b1260045271", size = 4991412, upload-time = "2026-01-07T18:18:34.649Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/82703abfc8b96a3f5000b2edca28d6f093d07185022dd60a2e463f0c59a7/surrealdb-1.0.8-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:ca0d6d4deee59f2100580da8ca2df449543d2c2945dc12299217b712278bf812", size = 5789423, upload-time = "2026-01-07T18:18:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/dd598d0519ed537bd033f79dc7d008adee88469cc8a0e60e33a57d51989e/surrealdb-1.0.8-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b85d2ae0f43306496690081a07b06231f30383952280002275fe6083eafc2a2a", size = 5686857, upload-time = "2026-01-07T18:18:37.702Z" }, + { url = "https://files.pythonhosted.org/packages/e1/db/5e24536cb158edcd1a40992811ed49ad4b911b330cedc84371bfa0c1d160/surrealdb-1.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:977c5f4602d16476f70557c6e729c4035c6323be580a756a26f79a103e0df46d", size = 5047898, upload-time = "2026-01-07T18:18:39.085Z" }, +] + [[package]] name = "taskiq" version = "0.12.1" @@ -4101,18 +4118,17 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.1.0" +version = "20.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, - { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, + { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, ] [[package]] @@ -4230,6 +4246,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "yappi" version = "1.7.3" @@ -4285,142 +4369,128 @@ wheels = [ [[package]] name = "yarl" -version = "1.23.0" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, - { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, - { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, - { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, - { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, - { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, - { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, - { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, - { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, - { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, - { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, - { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, - { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, - { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, - { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, - { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, - { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, - { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, - { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, - { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { 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" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] [[package]]