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 00000000..5d44b29a Binary files /dev/null and b/agentic_mesh_protocol-0.2.2-py3-none-any.whl differ diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test index 19ce304b..b4701fda 100644 --- a/docker/Dockerfile.test +++ b/docker/Dockerfile.test @@ -19,6 +19,7 @@ COPY pyproject.toml uv.lock /app/ # copy source & tests BEFORE running pip so editable install can see src COPY src/ /app/src/ COPY tests/ /app/tests/ +COPY *.whl . # install project (editable) + extras; this layer installs heavy deps one time RUN uv pip install --system -e ".[taskiq]" && \ diff --git a/docker/entrypoint-test.sh b/docker/entrypoint-test.sh index 2b813fc4..85131563 100755 --- a/docker/entrypoint-test.sh +++ b/docker/entrypoint-test.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e +#reinstall amp +uv pip install --system /app/agentic_mesh_protocol-*.whl 2>/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]]