From 45110eda8527742c32569cef89592e13d4878c59 Mon Sep 17 00:00:00 2001 From: Jonah Date: Wed, 4 Feb 2026 13:05:35 -0500 Subject: [PATCH 1/2] feat(OMN-1732): Runtime dependency injection for zero-code nodes Implement ContractDependencyResolver that reads protocol dependencies from node contract.yaml files and resolves them from container.service_registry. Architecture (DEC-003): - Container: service provider (owns protocol instances) - Runtime (resolver): wiring authority (resolves + validates) - Node: consumer (receives resolved deps via constructor) Components added: - ContractDependencyResolver: Extracts deps from contracts, resolves from container - ModelResolvedDependencies: Pydantic model for resolved protocol instances - ProtocolDependencyResolutionError: Fail-fast error for missing protocols Changes: - NodeContractPersistenceEffect accepts optional dependencies parameter - RegistryInfra*.get_required_protocols() deprecated (contract is source of truth) Includes 31 unit tests covering resolution, fail-fast behavior, and mock injection. --- src/omnibase_infra/errors/__init__.py | 4 + src/omnibase_infra/errors/error_infra.py | 60 ++ src/omnibase_infra/models/runtime/__init__.py | 4 + .../runtime/model_resolved_dependencies.py | 116 ++++ .../node_contract_persistence_effect/node.py | 19 +- ...istry_infra_contract_persistence_effect.py | 35 +- src/omnibase_infra/runtime/__init__.py | 7 + .../runtime/contract_dependency_resolver.py | 328 +++++++++++ .../services/contract_publisher/service.py | 4 +- .../test_contract_dependency_resolver.py | 548 ++++++++++++++++++ .../contract_publisher/test_service.py | 2 +- 11 files changed, 1122 insertions(+), 5 deletions(-) create mode 100644 src/omnibase_infra/models/runtime/model_resolved_dependencies.py create mode 100644 src/omnibase_infra/runtime/contract_dependency_resolver.py create mode 100644 tests/unit/runtime/test_contract_dependency_resolver.py diff --git a/src/omnibase_infra/errors/__init__.py b/src/omnibase_infra/errors/__init__.py index b1ad714988..4e32bf85ce 100644 --- a/src/omnibase_infra/errors/__init__.py +++ b/src/omnibase_infra/errors/__init__.py @@ -10,6 +10,7 @@ ModelInfraErrorContext: Configuration model for bundled error context RuntimeHostError: Base infrastructure error class ProtocolConfigurationError: Protocol configuration validation errors + ProtocolDependencyResolutionError: Protocol dependency resolution errors SecretResolutionError: Secret/credential resolution errors InfraConnectionError: Infrastructure connection errors InfraTimeoutError: Infrastructure timeout errors @@ -115,6 +116,7 @@ InfraTimeoutError, InfraUnavailableError, ProtocolConfigurationError, + ProtocolDependencyResolutionError, RuntimeHostError, SecretResolutionError, UnknownHandlerTypeError, @@ -162,6 +164,8 @@ "ModelTimeoutErrorContext", "PolicyRegistryError", "ProtocolConfigurationError", + # Protocol dependency resolution errors + "ProtocolDependencyResolutionError", # Repository errors "RepositoryContractError", "RepositoryError", diff --git a/src/omnibase_infra/errors/error_infra.py b/src/omnibase_infra/errors/error_infra.py index ef8a73ad88..e021bc72f8 100644 --- a/src/omnibase_infra/errors/error_infra.py +++ b/src/omnibase_infra/errors/error_infra.py @@ -597,6 +597,65 @@ def __init__( ) +class ProtocolDependencyResolutionError(RuntimeHostError): + """Raised when protocol dependencies cannot be resolved from container. + + Used when a node's contract.yaml declares protocol dependencies that + are not registered in the container's service_registry. This is a + fail-fast error that prevents node creation with missing dependencies. + + Example: + >>> context = ModelInfraErrorContext( + ... transport_type=EnumInfraTransportType.RUNTIME, + ... operation="resolve_dependencies", + ... target_name="NodeContractPersistenceEffect", + ... ) + >>> raise ProtocolDependencyResolutionError( + ... "Missing required protocols for node", + ... context=context, + ... missing_protocols=["ProtocolPostgresAdapter"], + ... node_name="node_contract_persistence_effect", + ... ) + + .. versionadded:: 0.x.x + Part of OMN-1732 runtime dependency injection. + """ + + def __init__( + self, + message: str, + context: ModelInfraErrorContext | None = None, + *, + missing_protocols: list[str] | None = None, + node_name: str | None = None, + contract_path: str | None = None, + **extra_context: object, + ) -> None: + """Initialize ProtocolDependencyResolutionError. + + Args: + message: Human-readable error message + context: Bundled infrastructure context + missing_protocols: List of protocol class names that could not be resolved + node_name: Name of the node requiring the protocols + contract_path: Path to the contract.yaml file (for debugging) + **extra_context: Additional context information + """ + if missing_protocols is not None: + extra_context["missing_protocols"] = missing_protocols + if node_name is not None: + extra_context["node_name"] = node_name + if contract_path is not None: + extra_context["contract_path"] = contract_path + + super().__init__( + message=message, + error_code=EnumCoreErrorCode.DEPENDENCY_ERROR, + context=context, + **extra_context, + ) + + __all__: list[str] = [ "EnvelopeValidationError", "InfraAuthenticationError", @@ -604,6 +663,7 @@ def __init__( "InfraTimeoutError", "InfraUnavailableError", "ProtocolConfigurationError", + "ProtocolDependencyResolutionError", "RuntimeHostError", "SecretResolutionError", "UnknownHandlerTypeError", diff --git a/src/omnibase_infra/models/runtime/__init__.py b/src/omnibase_infra/models/runtime/__init__.py index e36a5702ee..e819ff38a6 100644 --- a/src/omnibase_infra/models/runtime/__init__.py +++ b/src/omnibase_infra/models/runtime/__init__.py @@ -26,6 +26,9 @@ from omnibase_infra.models.runtime.model_plugin_load_summary import ( ModelPluginLoadSummary, ) +from omnibase_infra.models.runtime.model_resolved_dependencies import ( + ModelResolvedDependencies, +) # ModelContractLoadResult and ModelRuntimeContractConfig are exported from # omnibase_infra.runtime.models (canonical location for runtime loader models) @@ -45,5 +48,6 @@ "ModelLoadedHandler", "ModelPluginLoadContext", "ModelPluginLoadSummary", + "ModelResolvedDependencies", "ModelRuntimeContractConfig", ] diff --git a/src/omnibase_infra/models/runtime/model_resolved_dependencies.py b/src/omnibase_infra/models/runtime/model_resolved_dependencies.py new file mode 100644 index 0000000000..33421aaa88 --- /dev/null +++ b/src/omnibase_infra/models/runtime/model_resolved_dependencies.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Model for resolved protocol dependencies. + +This module provides ModelResolvedDependencies, a container for protocol +instances resolved from the container service_registry at node creation time. + +Part of OMN-1732: Runtime dependency injection for zero-code nodes. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ModelResolvedDependencies(BaseModel): + """Container for resolved protocol dependencies. + + Holds protocol instances resolved from ModelONEXContainer.service_registry + for injection into node constructors. This model is immutable after creation. + + The protocols dict maps protocol class names to their resolved instances: + - Key: Protocol class name (e.g., "ProtocolPostgresAdapter") + - Value: Resolved instance from container + + Example: + >>> resolved = ModelResolvedDependencies( + ... protocols={ + ... "ProtocolPostgresAdapter": postgres_adapter, + ... "ProtocolCircuitBreakerAware": circuit_breaker, + ... } + ... ) + >>> adapter = resolved.get("ProtocolPostgresAdapter") + + .. versionadded:: 0.x.x + Part of OMN-1732 runtime dependency injection. + """ + + model_config = ConfigDict( + frozen=True, + extra="forbid", + arbitrary_types_allowed=True, # Required for protocol instances + ) + + # ONEX_EXCLUDE: any_type - dict[str, Any] required for heterogeneous protocol instances + # resolved from container.service_registry. Type varies by protocol (ProtocolPostgresAdapter, + # ProtocolCircuitBreakerAware, etc.). Cannot use Union as protocols are open-ended. + protocols: dict[str, Any] = Field( + default_factory=dict, + description="Map of protocol class names to resolved instances", + ) + + # ONEX_EXCLUDE: any_type - returns heterogeneous protocol instance from protocols dict + def get(self, protocol_name: str) -> Any: + """Get a resolved protocol by name. + + Args: + protocol_name: The protocol class name (e.g., "ProtocolPostgresAdapter") + + Returns: + The resolved protocol instance. + + Raises: + KeyError: If protocol_name is not in the resolved protocols. + + Example: + >>> adapter = resolved.get("ProtocolPostgresAdapter") + """ + if protocol_name not in self.protocols: + raise KeyError( + f"Protocol '{protocol_name}' not found in resolved dependencies. " + f"Available: {list(self.protocols.keys())}" + ) + return self.protocols[protocol_name] + + # ONEX_EXCLUDE: any_type - returns heterogeneous protocol instance, default can be any type + def get_optional(self, protocol_name: str, default: Any = None) -> Any: + """Get a resolved protocol by name, returning default if not found. + + Args: + protocol_name: The protocol class name + default: Value to return if protocol not found + + Returns: + The resolved protocol instance or default. + """ + return self.protocols.get(protocol_name, default) + + def has(self, protocol_name: str) -> bool: + """Check if a protocol is available. + + Args: + protocol_name: The protocol class name + + Returns: + True if protocol is resolved, False otherwise. + """ + return protocol_name in self.protocols + + def __len__(self) -> int: + """Return number of resolved protocols.""" + return len(self.protocols) + + def __bool__(self) -> bool: + """Return True if any protocols are resolved. + + Warning: + **Non-standard __bool__ behavior**: Returns True only when + at least one protocol is resolved. + """ + return len(self.protocols) > 0 + + +__all__ = ["ModelResolvedDependencies"] diff --git a/src/omnibase_infra/nodes/node_contract_persistence_effect/node.py b/src/omnibase_infra/nodes/node_contract_persistence_effect/node.py index cb06993cfe..0c74154d31 100644 --- a/src/omnibase_infra/nodes/node_contract_persistence_effect/node.py +++ b/src/omnibase_infra/nodes/node_contract_persistence_effect/node.py @@ -56,8 +56,14 @@ if TYPE_CHECKING: from omnibase_core.models.container.model_onex_container import ModelONEXContainer + from omnibase_infra.models.runtime.model_resolved_dependencies import ( + ModelResolvedDependencies, + ) +# ONEX_EXCLUDE: declarative_node - OMN-1732 DEC-003 requires constructor injection +# for protocol dependencies. The _resolved_dependencies instance variable stores +# pre-resolved protocols from ContractDependencyResolver. class NodeContractPersistenceEffect(NodeEffect): """Declarative effect node for contract registry persistence. @@ -76,6 +82,9 @@ class NodeContractPersistenceEffect(NodeEffect): Args: container: ONEX dependency injection container. + dependencies: Optional pre-resolved protocol dependencies. If provided, + the node will use these instead of resolving from container. + Part of OMN-1732 runtime dependency injection. Dependency Injection: Backend adapters (PostgreSQL) are resolved via container. @@ -102,13 +111,21 @@ class NodeContractPersistenceEffect(NodeEffect): ``` """ - def __init__(self, container: ModelONEXContainer) -> None: + def __init__( + self, + container: ModelONEXContainer, + dependencies: ModelResolvedDependencies | None = None, + ) -> None: """Initialize effect node with container dependency injection. Args: container: ONEX dependency injection container. + dependencies: Optional pre-resolved protocol dependencies from + ContractDependencyResolver. If provided, the node uses these + instead of resolving from container. Part of OMN-1732. """ super().__init__(container) + self._resolved_dependencies = dependencies __all__ = ["NodeContractPersistenceEffect"] diff --git a/src/omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py b/src/omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py index fa299ec7bc..c10f22bbcb 100644 --- a/src/omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py +++ b/src/omnibase_infra/nodes/node_contract_persistence_effect/registry/registry_infra_contract_persistence_effect.py @@ -24,10 +24,14 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING if TYPE_CHECKING: from omnibase_core.models.container.model_onex_container import ModelONEXContainer + from omnibase_infra.models.runtime.model_resolved_dependencies import ( + ModelResolvedDependencies, + ) from omnibase_infra.nodes.node_contract_persistence_effect.node import ( NodeContractPersistenceEffect, ) @@ -62,7 +66,10 @@ class RegistryInfraContractPersistenceEffect: """ @staticmethod - def create(container: ModelONEXContainer) -> NodeContractPersistenceEffect: + def create( + container: ModelONEXContainer, + dependencies: ModelResolvedDependencies | None = None, + ) -> NodeContractPersistenceEffect: """Create a NodeContractPersistenceEffect instance with resolved dependencies. Factory method that creates a fully configured NodeContractPersistenceEffect @@ -73,6 +80,10 @@ def create(container: ModelONEXContainer) -> NodeContractPersistenceEffect: following protocols registered: - ProtocolPostgresAdapter: PostgreSQL database operations - ProtocolCircuitBreakerAware: Backend circuit breaker protection + dependencies: Optional pre-resolved protocol dependencies from + ContractDependencyResolver. If provided, the node uses these + instead of resolving from container. Part of OMN-1732 runtime + dependency injection. Returns: Configured NodeContractPersistenceEffect instance ready for operation. @@ -84,14 +95,22 @@ def create(container: ModelONEXContainer) -> NodeContractPersistenceEffect: >>> container = ModelONEXContainer() >>> container.register(ProtocolPostgresAdapter, postgres_adapter) >>> effect = RegistryInfraContractPersistenceEffect.create(container) + >>> + >>> # With pre-resolved dependencies (OMN-1732) + >>> resolved = resolver.resolve(contract) + >>> effect = RegistryInfraContractPersistenceEffect.create( + ... container, dependencies=resolved + ... ) .. versionadded:: 0.5.0 + .. versionchanged:: 0.6.0 + Added optional ``dependencies`` parameter for constructor injection (OMN-1732). """ from omnibase_infra.nodes.node_contract_persistence_effect.node import ( NodeContractPersistenceEffect, ) - return NodeContractPersistenceEffect(container) + return NodeContractPersistenceEffect(container, dependencies=dependencies) @staticmethod def get_required_protocols() -> list[str]: @@ -100,6 +119,11 @@ def get_required_protocols() -> list[str]: Returns the protocol class names that must be registered in the container before creating a NodeContractPersistenceEffect instance. + .. deprecated:: 0.6.0 + Use contract.yaml dependencies field instead. This method will be + removed in a future version. The contract is now the single source + of truth for protocol requirements (OMN-1732). + Returns: List of protocol class names required for node operation. @@ -111,6 +135,13 @@ def get_required_protocols() -> list[str]: .. versionadded:: 0.5.0 """ + warnings.warn( + "get_required_protocols() is deprecated. Use contract.yaml dependencies " + "field instead. The contract is the single source of truth for protocol " + "requirements (OMN-1732).", + DeprecationWarning, + stacklevel=2, + ) return [ "ProtocolPostgresAdapter", "ProtocolCircuitBreakerAware", diff --git a/src/omnibase_infra/runtime/__init__.py b/src/omnibase_infra/runtime/__init__.py index 12d89066a4..e1378d7567 100644 --- a/src/omnibase_infra/runtime/__init__.py +++ b/src/omnibase_infra/runtime/__init__.py @@ -282,6 +282,11 @@ get_baseline_topics, ) +# Contract dependency resolver (OMN-1732) +from omnibase_infra.runtime.contract_dependency_resolver import ( + ContractDependencyResolver, +) + # Chain-aware dispatch (OMN-951) - must be imported LAST to avoid circular import from omnibase_infra.runtime.chain_aware_dispatch import ( ChainAwareDispatcher, @@ -458,4 +463,6 @@ "BASELINE_CONTRACT_TOPICS", "BASELINE_PLATFORM_TOPICS", "get_baseline_topics", + # Contract dependency resolver (OMN-1732) + "ContractDependencyResolver", ] diff --git a/src/omnibase_infra/runtime/contract_dependency_resolver.py b/src/omnibase_infra/runtime/contract_dependency_resolver.py new file mode 100644 index 0000000000..ce33ae8821 --- /dev/null +++ b/src/omnibase_infra/runtime/contract_dependency_resolver.py @@ -0,0 +1,328 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Contract dependency resolver for ONEX nodes. + +This module provides ContractDependencyResolver, which reads protocol +dependencies from a node's contract.yaml and resolves them from the +container's service_registry. + +Part of OMN-1732: Runtime dependency injection for zero-code nodes. + +Architecture: + - Container: service provider (owns protocol instances) + - Runtime (this resolver): wiring authority (resolves + validates) + - Node: consumer (receives resolved deps via constructor) +""" + +from __future__ import annotations + +import importlib +import logging +from typing import TYPE_CHECKING, Any, cast + +from omnibase_infra.enums import EnumInfraTransportType +from omnibase_infra.errors import ( + ModelInfraErrorContext, + ProtocolDependencyResolutionError, +) +from omnibase_infra.models.runtime.model_resolved_dependencies import ( + ModelResolvedDependencies, +) + +if TYPE_CHECKING: + from omnibase_core.container import ModelONEXContainer + from omnibase_core.models.contracts import ModelContractBase + +logger = logging.getLogger(__name__) + + +class ContractDependencyResolver: + """Resolves protocol dependencies from contracts using container. + + Reads the `dependencies` field from a node's contract, filters for + protocol-type dependencies, imports the protocol classes, and resolves + instances from the container's service_registry. + + This resolver implements the "fail-fast" principle: if any required + protocol cannot be resolved, it raises ProtocolDependencyResolutionError + immediately rather than allowing node creation with missing dependencies. + + Example: + >>> from omnibase_core.container import ModelONEXContainer + >>> + >>> container = ModelONEXContainer() + >>> # ... register protocols in container ... + >>> + >>> resolver = ContractDependencyResolver(container) + >>> resolved = await resolver.resolve(node_contract) + >>> + >>> # Use resolved dependencies for node creation + >>> node = NodeRegistry.create(container, dependencies=resolved) + + .. versionadded:: 0.x.x + Part of OMN-1732 runtime dependency injection. + """ + + def __init__(self, container: ModelONEXContainer) -> None: + """Initialize the resolver with a container. + + Args: + container: ONEX container with service_registry for protocol resolution. + """ + self._container = container + + async def resolve( + self, + contract: ModelContractBase, + *, + allow_missing: bool = False, + ) -> ModelResolvedDependencies: + """Resolve protocol dependencies from a contract. + + Reads contract.dependencies, filters for protocol-type entries, + and resolves each from the container's service_registry. + + Args: + contract: The node contract containing dependency declarations. + allow_missing: If True, skip missing protocols instead of raising. + Default False (fail-fast behavior). + + Returns: + ModelResolvedDependencies containing resolved protocol instances. + + Raises: + ProtocolDependencyResolutionError: If any required protocol cannot + be resolved and allow_missing is False. + + Example: + >>> resolved = await resolver.resolve(contract) + >>> adapter = resolved.get("ProtocolPostgresAdapter") + """ + # Extract protocol dependencies from contract + protocol_deps = self._extract_protocol_dependencies(contract) + + if not protocol_deps: + logger.debug( + "No protocol dependencies declared in contract", + extra={"contract_name": getattr(contract, "name", "unknown")}, + ) + return ModelResolvedDependencies() + + # Resolve each protocol from container + # ONEX_EXCLUDE: any_type - dict holds heterogeneous protocol instances resolved at runtime + resolved: dict[str, Any] = {} + missing: list[str] = [] + resolution_errors: dict[str, str] = {} + + for dep in protocol_deps: + class_name = dep.get("class_name") + module_path = dep.get("module") + + if not class_name: + logger.warning( + "Protocol dependency missing class_name, skipping", + extra={"dependency": dep}, + ) + continue + + try: + # Import the protocol class + protocol_class = self._import_protocol_class(class_name, module_path) + + # Resolve from container + instance = await self._resolve_from_container(protocol_class) + resolved[class_name] = instance + + logger.debug( + "Resolved protocol dependency", + extra={ + "protocol": class_name, + "module_path": module_path, + }, + ) + + except Exception as e: + error_msg = str(e) + resolution_errors[class_name] = error_msg + missing.append(class_name) + + logger.warning( + "Failed to resolve protocol dependency", + extra={ + "protocol": class_name, + "module_path": module_path, + "error": error_msg, + }, + ) + + # Fail-fast if any protocols are missing + if missing and not allow_missing: + contract_name = getattr(contract, "name", "unknown") + context = ModelInfraErrorContext.with_correlation( + transport_type=EnumInfraTransportType.RUNTIME, + operation="resolve_dependencies", + target_name=contract_name, + ) + + error_details = "\n".join( + f" - {proto}: {resolution_errors.get(proto, 'unknown error')}" + for proto in missing + ) + + raise ProtocolDependencyResolutionError( + f"Cannot create node '{contract_name}': missing required protocols.\n" + f"The following protocols are declared in contract.yaml but could not " + f"be resolved from the container:\n{error_details}\n\n" + f"Ensure these protocols are registered in the container before " + f"node creation via container.service_registry.register_instance().", + context=context, + missing_protocols=missing, + node_name=contract_name, + ) + + return ModelResolvedDependencies(protocols=resolved) + + def _extract_protocol_dependencies( + self, + contract: ModelContractBase, + ) -> list[dict[str, str]]: + """Extract protocol-type dependencies from contract. + + Supports multiple detection patterns: + 1. `type` field = "protocol" (YAML contract style) + 2. `is_protocol()` method returns True + 3. `dependency_type` attribute with value "PROTOCOL" + + Args: + contract: The contract to extract dependencies from. + + Returns: + List of dependency dicts with keys: name, type, class_name, module + """ + dependencies: list[dict[str, str]] = [] + + # Get dependencies attribute if present + contract_deps = getattr(contract, "dependencies", None) + if not contract_deps: + return dependencies + + for dep in contract_deps: + is_protocol = self._is_protocol_dependency(dep) + + if is_protocol: + dep_dict: dict[str, str] = {} + + # Extract fields - handle both attribute and dict access + if hasattr(dep, "name"): + dep_dict["name"] = str(dep.name) if dep.name else "" + if hasattr(dep, "class_name"): + dep_dict["class_name"] = ( + str(dep.class_name) if dep.class_name else "" + ) + if hasattr(dep, "module"): + dep_dict["module"] = str(dep.module) if dep.module else "" + + # Only add if we have a class_name + if dep_dict.get("class_name"): + dependencies.append(dep_dict) + + return dependencies + + # ONEX_EXCLUDE: any_type - dep can be ModelContractDependency or dict from various sources + def _is_protocol_dependency(self, dep: Any) -> bool: + """Check if a dependency is a protocol dependency. + + Supports multiple detection patterns: + 1. `type` field = "protocol" (YAML contract style) + 2. `is_protocol()` method returns True + 3. `dependency_type` attribute with value "PROTOCOL" + + Args: + dep: The dependency object to check. + + Returns: + True if this is a protocol dependency, False otherwise. + """ + # Check is_protocol() method first (most explicit) + if hasattr(dep, "is_protocol") and callable(dep.is_protocol): + if dep.is_protocol(): + return True + + # Check dependency_type attribute (enum or string) + if hasattr(dep, "dependency_type"): + dep_type_val = dep.dependency_type + if hasattr(dep_type_val, "value"): + # Enum with .value + if str(dep_type_val.value).upper() == "PROTOCOL": + return True + elif str(dep_type_val).upper() == "PROTOCOL": + return True + + # Check type field (YAML contract style: type: "protocol") + if hasattr(dep, "type"): + type_val = getattr(dep, "type", "") + if str(type_val).lower() == "protocol": + return True + + return False + + def _import_protocol_class( + self, + class_name: str, + module_path: str | None, + ) -> type: + """Import a protocol class by name and module. + + Args: + class_name: The class name to import + module_path: The module path (required for import) + + Returns: + The imported class type. + + Raises: + ImportError: If the class cannot be imported. + """ + if not module_path: + raise ImportError( + f"Protocol '{class_name}' has no module path specified in contract. " + f"Cannot import without module path." + ) + + try: + module = importlib.import_module(module_path) + protocol_class = getattr(module, class_name) + return cast("type", protocol_class) + except ModuleNotFoundError as e: + raise ImportError( + f"Module '{module_path}' not found for protocol '{class_name}': {e}" + ) from e + except AttributeError as e: + raise ImportError( + f"Class '{class_name}' not found in module '{module_path}': {e}" + ) from e + + # ONEX_EXCLUDE: any_type - returns protocol instance, type varies by resolved protocol class + async def _resolve_from_container(self, protocol_class: type) -> Any: + """Resolve a protocol instance from the container. + + Args: + protocol_class: The protocol class type to resolve. + + Returns: + The resolved protocol instance. + + Raises: + RuntimeError: If container.service_registry is None. + Exception: If resolution fails (propagated from container). + """ + if self._container.service_registry is None: + raise RuntimeError( + "Container service_registry is None. " + "Ensure container is properly initialized with service_registry enabled." + ) + + return await self._container.service_registry.resolve_service(protocol_class) + + +__all__ = ["ContractDependencyResolver"] diff --git a/src/omnibase_infra/services/contract_publisher/service.py b/src/omnibase_infra/services/contract_publisher/service.py index 1e7a6676e7..7d143d9f33 100644 --- a/src/omnibase_infra/services/contract_publisher/service.py +++ b/src/omnibase_infra/services/contract_publisher/service.py @@ -26,7 +26,6 @@ import yaml from pydantic import ValidationError -from omnibase_core.constants import TOPIC_SUFFIX_CONTRACT_REGISTERED from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract from omnibase_core.models.events import ModelContractRegisteredEvent from omnibase_core.protocols.event_bus import ProtocolEventBusPublisher @@ -35,6 +34,9 @@ InfraTimeoutError, InfraUnavailableError, ) +from omnibase_infra.runtime.contract_registration_event_router import ( + TOPIC_SUFFIX_CONTRACT_REGISTERED, +) from omnibase_infra.services.contract_publisher.config import ( ModelContractPublisherConfig, ) diff --git a/tests/unit/runtime/test_contract_dependency_resolver.py b/tests/unit/runtime/test_contract_dependency_resolver.py new file mode 100644 index 0000000000..475a4aed3b --- /dev/null +++ b/tests/unit/runtime/test_contract_dependency_resolver.py @@ -0,0 +1,548 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Unit tests for ContractDependencyResolver. + +Part of OMN-1732: Runtime dependency injection for zero-code nodes. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from omnibase_infra.errors import ProtocolDependencyResolutionError +from omnibase_infra.models.runtime.model_resolved_dependencies import ( + ModelResolvedDependencies, +) +from omnibase_infra.runtime.contract_dependency_resolver import ( + ContractDependencyResolver, +) + + +class TestContractDependencyResolver: + """Tests for ContractDependencyResolver.""" + + @pytest.fixture + def mock_container(self) -> MagicMock: + """Create a mock ONEX container with service_registry.""" + container = MagicMock() + container.service_registry = MagicMock() + container.service_registry.resolve_service = AsyncMock() + return container + + @pytest.fixture + def mock_contract_with_protocols(self) -> MagicMock: + """Create a mock contract with protocol dependencies.""" + contract = MagicMock() + contract.name = "test_node" + + # Mock dependency with protocol type using is_protocol() method + dep1 = MagicMock() + dep1.name = "protocol_postgres" + dep1.class_name = "ProtocolPostgresAdapter" + dep1.module = "omnibase_infra.adapters.protocol_postgres_adapter" + dep1.is_protocol = MagicMock(return_value=True) + + dep2 = MagicMock() + dep2.name = "protocol_circuit_breaker" + dep2.class_name = "ProtocolCircuitBreakerAware" + dep2.module = "omnibase_infra.mixins.protocol_circuit_breaker_aware" + dep2.is_protocol = MagicMock(return_value=True) + + contract.dependencies = [dep1, dep2] + return contract + + @pytest.fixture + def mock_contract_no_protocols(self) -> MagicMock: + """Create a mock contract with no dependencies.""" + contract = MagicMock() + contract.name = "test_node_no_deps" + contract.dependencies = [] + return contract + + @pytest.fixture + def mock_contract_with_type_field(self) -> MagicMock: + """Create a mock contract using type='protocol' field style.""" + contract = MagicMock() + contract.name = "test_node_type_field" + + dep = MagicMock() + dep.name = "protocol_test" + dep.class_name = "ProtocolTestAdapter" + dep.module = "test.module" + dep.type = "protocol" + # No is_protocol method - uses type field instead + del dep.is_protocol + + contract.dependencies = [dep] + return contract + + @pytest.fixture + def mock_contract_with_dependency_type_enum(self) -> MagicMock: + """Create a mock contract using dependency_type enum.""" + contract = MagicMock() + contract.name = "test_node_enum" + + dep = MagicMock() + dep.name = "protocol_enum" + dep.class_name = "ProtocolEnumAdapter" + dep.module = "test.module.enum" + dep.dependency_type = MagicMock(value="PROTOCOL") + # No is_protocol method - uses dependency_type attribute instead + del dep.is_protocol + + contract.dependencies = [dep] + return contract + + @pytest.mark.asyncio + async def test_resolve_empty_dependencies_returns_empty( + self, + mock_container: MagicMock, + mock_contract_no_protocols: MagicMock, + ) -> None: + """Test that contracts with no dependencies return empty result.""" + resolver = ContractDependencyResolver(mock_container) + + result = await resolver.resolve(mock_contract_no_protocols) + + assert isinstance(result, ModelResolvedDependencies) + assert len(result) == 0 + mock_container.service_registry.resolve_service.assert_not_called() + + @pytest.mark.asyncio + async def test_resolve_extracts_protocol_dependencies_is_protocol_method( + self, + mock_container: MagicMock, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test that protocol dependencies are extracted using is_protocol() method.""" + resolver = ContractDependencyResolver(mock_container) + + deps = resolver._extract_protocol_dependencies(mock_contract_with_protocols) + + assert len(deps) == 2 + assert deps[0]["class_name"] == "ProtocolPostgresAdapter" + assert deps[1]["class_name"] == "ProtocolCircuitBreakerAware" + + @pytest.mark.asyncio + async def test_resolve_extracts_protocol_dependencies_type_field( + self, + mock_container: MagicMock, + mock_contract_with_type_field: MagicMock, + ) -> None: + """Test that protocol dependencies are extracted using type='protocol' field.""" + resolver = ContractDependencyResolver(mock_container) + + deps = resolver._extract_protocol_dependencies(mock_contract_with_type_field) + + assert len(deps) == 1 + assert deps[0]["class_name"] == "ProtocolTestAdapter" + + @pytest.mark.asyncio + async def test_resolve_extracts_protocol_dependencies_enum_type( + self, + mock_container: MagicMock, + mock_contract_with_dependency_type_enum: MagicMock, + ) -> None: + """Test that protocol dependencies are extracted using dependency_type enum.""" + resolver = ContractDependencyResolver(mock_container) + + deps = resolver._extract_protocol_dependencies( + mock_contract_with_dependency_type_enum + ) + + assert len(deps) == 1 + assert deps[0]["class_name"] == "ProtocolEnumAdapter" + + @pytest.mark.asyncio + async def test_resolve_success_returns_resolved_dependencies( + self, + mock_container: MagicMock, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test successful resolution returns ModelResolvedDependencies.""" + # Setup mock instances + mock_postgres = MagicMock() + mock_circuit_breaker = MagicMock() + + mock_container.service_registry.resolve_service = AsyncMock( + side_effect=[mock_postgres, mock_circuit_breaker] + ) + + resolver = ContractDependencyResolver(mock_container) + + # Mock the import to avoid actual module import + with patch.object(resolver, "_import_protocol_class") as mock_import: + mock_import.side_effect = [ + MagicMock(__name__="ProtocolPostgresAdapter"), + MagicMock(__name__="ProtocolCircuitBreakerAware"), + ] + + result = await resolver.resolve(mock_contract_with_protocols) + + assert isinstance(result, ModelResolvedDependencies) + assert len(result) == 2 + assert result.get("ProtocolPostgresAdapter") is mock_postgres + assert result.get("ProtocolCircuitBreakerAware") is mock_circuit_breaker + + @pytest.mark.asyncio + async def test_resolve_missing_protocol_raises_error( + self, + mock_container: MagicMock, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test that missing protocols raise ProtocolDependencyResolutionError.""" + # Setup container to fail resolution + mock_container.service_registry.resolve_service = AsyncMock( + side_effect=Exception("Protocol not registered") + ) + + resolver = ContractDependencyResolver(mock_container) + + with patch.object(resolver, "_import_protocol_class") as mock_import: + mock_import.return_value = MagicMock() + + with pytest.raises(ProtocolDependencyResolutionError) as exc_info: + await resolver.resolve(mock_contract_with_protocols) + + assert "missing required protocols" in str(exc_info.value).lower() + # Check that error context contains missing_protocols + context = exc_info.value.model.context + assert isinstance(context, dict) + assert context.get("missing_protocols") is not None + missing = context["missing_protocols"] + assert isinstance(missing, list) + assert "ProtocolPostgresAdapter" in missing + assert "ProtocolCircuitBreakerAware" in missing + + @pytest.mark.asyncio + async def test_resolve_allow_missing_skips_failures( + self, + mock_container: MagicMock, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test that allow_missing=True skips failed resolutions.""" + # Setup container to fail resolution + mock_container.service_registry.resolve_service = AsyncMock( + side_effect=Exception("Protocol not registered") + ) + + resolver = ContractDependencyResolver(mock_container) + + with patch.object(resolver, "_import_protocol_class") as mock_import: + mock_import.return_value = MagicMock() + + # Should not raise with allow_missing=True + result = await resolver.resolve( + mock_contract_with_protocols, + allow_missing=True, + ) + + assert isinstance(result, ModelResolvedDependencies) + assert len(result) == 0 # All failed, but no error raised + + @pytest.mark.asyncio + async def test_resolve_none_service_registry_raises_error( + self, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test that None service_registry raises ProtocolDependencyResolutionError. + + Note: The RuntimeError is caught internally and wrapped in + ProtocolDependencyResolutionError as part of the fail-fast behavior. + """ + container = MagicMock() + container.service_registry = None + + resolver = ContractDependencyResolver(container) + + with patch.object(resolver, "_import_protocol_class") as mock_import: + mock_import.return_value = MagicMock() + + with pytest.raises(ProtocolDependencyResolutionError) as exc_info: + await resolver.resolve(mock_contract_with_protocols) + + # The RuntimeError message is included in the wrapped error + assert "service_registry is None" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_resolve_partial_success_raises_error( + self, + mock_container: MagicMock, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test partial resolution (some succeed, some fail) raises error.""" + mock_postgres = MagicMock() + + # First succeeds, second fails + mock_container.service_registry.resolve_service = AsyncMock( + side_effect=[mock_postgres, Exception("Second protocol not found")] + ) + + resolver = ContractDependencyResolver(mock_container) + + with patch.object(resolver, "_import_protocol_class") as mock_import: + mock_import.return_value = MagicMock() + + with pytest.raises(ProtocolDependencyResolutionError) as exc_info: + await resolver.resolve(mock_contract_with_protocols) + + # Should list only the failed protocol + context = exc_info.value.model.context + assert isinstance(context, dict) + missing = context["missing_protocols"] + assert isinstance(missing, list) + assert len(missing) == 1 + assert "ProtocolCircuitBreakerAware" in missing + + @pytest.mark.asyncio + async def test_resolve_skips_dependency_without_class_name( + self, + mock_container: MagicMock, + ) -> None: + """Test that dependencies without class_name are skipped.""" + contract = MagicMock() + contract.name = "test_node" + + # Dependency with protocol type but no class_name + dep = MagicMock() + dep.name = "no_class" + dep.class_name = None # Missing class_name + dep.module = "test.module" + dep.is_protocol = MagicMock(return_value=True) + + contract.dependencies = [dep] + + resolver = ContractDependencyResolver(mock_container) + + result = await resolver.resolve(contract) + + assert len(result) == 0 + mock_container.service_registry.resolve_service.assert_not_called() + + @pytest.mark.asyncio + async def test_import_protocol_class_no_module_raises( + self, + mock_container: MagicMock, + ) -> None: + """Test _import_protocol_class raises ImportError when no module path.""" + resolver = ContractDependencyResolver(mock_container) + + with pytest.raises(ImportError) as exc_info: + resolver._import_protocol_class("TestProtocol", None) + + assert "no module path specified" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_import_protocol_class_module_not_found( + self, + mock_container: MagicMock, + ) -> None: + """Test _import_protocol_class raises ImportError for nonexistent module.""" + resolver = ContractDependencyResolver(mock_container) + + with pytest.raises(ImportError) as exc_info: + resolver._import_protocol_class( + "TestProtocol", "nonexistent.module.path.that.does.not.exist" + ) + + assert "not found" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_import_protocol_class_class_not_in_module( + self, + mock_container: MagicMock, + ) -> None: + """Test _import_protocol_class raises ImportError for missing class.""" + resolver = ContractDependencyResolver(mock_container) + + # Use a real module but request a class that doesn't exist + with pytest.raises(ImportError) as exc_info: + resolver._import_protocol_class("NonExistentClass", "omnibase_infra.errors") + + assert "not found" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_is_protocol_dependency_false_for_non_protocol( + self, + mock_container: MagicMock, + ) -> None: + """Test _is_protocol_dependency returns False for non-protocol deps.""" + resolver = ContractDependencyResolver(mock_container) + + # Dependency with no protocol indicators + dep = MagicMock(spec=["name", "class_name"]) + dep.name = "regular_dep" + dep.class_name = "SomeClass" + + result = resolver._is_protocol_dependency(dep) + + assert result is False + + @pytest.mark.asyncio + async def test_extract_handles_missing_dependencies_attr( + self, + mock_container: MagicMock, + ) -> None: + """Test _extract_protocol_dependencies handles contract without dependencies.""" + contract = MagicMock(spec=["name"]) + contract.name = "no_deps_contract" + + resolver = ContractDependencyResolver(mock_container) + + deps = resolver._extract_protocol_dependencies(contract) + + assert deps == [] + + @pytest.mark.asyncio + async def test_error_includes_node_name( + self, + mock_container: MagicMock, + mock_contract_with_protocols: MagicMock, + ) -> None: + """Test that error includes node name in context.""" + mock_container.service_registry.resolve_service = AsyncMock( + side_effect=Exception("Resolution failed") + ) + + resolver = ContractDependencyResolver(mock_container) + + with patch.object(resolver, "_import_protocol_class") as mock_import: + mock_import.return_value = MagicMock() + + with pytest.raises(ProtocolDependencyResolutionError) as exc_info: + await resolver.resolve(mock_contract_with_protocols) + + assert exc_info.value.model.context.get("node_name") == "test_node" + + +class TestModelResolvedDependencies: + """Tests for ModelResolvedDependencies model.""" + + def test_get_returns_protocol(self) -> None: + """Test get() returns the protocol instance.""" + mock_adapter = MagicMock() + resolved = ModelResolvedDependencies( + protocols={"ProtocolPostgresAdapter": mock_adapter} + ) + + result = resolved.get("ProtocolPostgresAdapter") + + assert result is mock_adapter + + def test_get_raises_keyerror_for_missing(self) -> None: + """Test get() raises KeyError for missing protocol.""" + resolved = ModelResolvedDependencies(protocols={}) + + with pytest.raises(KeyError) as exc_info: + resolved.get("NonExistentProtocol") + + assert "NonExistentProtocol" in str(exc_info.value) + + def test_get_keyerror_message_includes_available(self) -> None: + """Test KeyError message lists available protocols.""" + resolved = ModelResolvedDependencies( + protocols={"ProtocolA": MagicMock(), "ProtocolB": MagicMock()} + ) + + with pytest.raises(KeyError) as exc_info: + resolved.get("NonExistent") + + error_msg = str(exc_info.value) + assert "ProtocolA" in error_msg + assert "ProtocolB" in error_msg + + def test_get_optional_returns_value(self) -> None: + """Test get_optional() returns value when present.""" + mock_adapter = MagicMock() + resolved = ModelResolvedDependencies(protocols={"ProtocolTest": mock_adapter}) + + result = resolved.get_optional("ProtocolTest") + + assert result is mock_adapter + + def test_get_optional_returns_default(self) -> None: + """Test get_optional() returns default for missing protocol.""" + resolved = ModelResolvedDependencies(protocols={}) + + result = resolved.get_optional("NonExistentProtocol", default="fallback") + + assert result == "fallback" + + def test_get_optional_returns_none_by_default(self) -> None: + """Test get_optional() returns None when default not specified.""" + resolved = ModelResolvedDependencies(protocols={}) + + result = resolved.get_optional("NonExistent") + + assert result is None + + def test_has_returns_true_for_existing(self) -> None: + """Test has() returns True for existing protocol.""" + resolved = ModelResolvedDependencies( + protocols={"ProtocolPostgresAdapter": MagicMock()} + ) + + assert resolved.has("ProtocolPostgresAdapter") is True + + def test_has_returns_false_for_missing(self) -> None: + """Test has() returns False for missing protocol.""" + resolved = ModelResolvedDependencies(protocols={}) + + assert resolved.has("NonExistent") is False + + def test_len_returns_protocol_count(self) -> None: + """Test __len__ returns number of protocols.""" + resolved = ModelResolvedDependencies( + protocols={ + "Protocol1": MagicMock(), + "Protocol2": MagicMock(), + } + ) + + assert len(resolved) == 2 + + def test_len_returns_zero_for_empty(self) -> None: + """Test __len__ returns 0 for empty protocols.""" + resolved = ModelResolvedDependencies(protocols={}) + + assert len(resolved) == 0 + + def test_bool_true_when_protocols_present(self) -> None: + """Test __bool__ returns True when protocols are present.""" + resolved = ModelResolvedDependencies(protocols={"Protocol1": MagicMock()}) + + assert bool(resolved) is True + + def test_bool_false_when_empty(self) -> None: + """Test __bool__ returns False when no protocols.""" + resolved = ModelResolvedDependencies(protocols={}) + + assert bool(resolved) is False + + def test_default_factory_creates_empty_dict(self) -> None: + """Test that ModelResolvedDependencies() creates empty protocols dict.""" + resolved = ModelResolvedDependencies() + + assert resolved.protocols == {} + assert len(resolved) == 0 + assert bool(resolved) is False + + def test_model_is_immutable(self) -> None: + """Test that the model is frozen (immutable).""" + resolved = ModelResolvedDependencies(protocols={"Test": MagicMock()}) + + with pytest.raises(Exception): # ValidationError or AttributeError + resolved.protocols = {} # type: ignore[misc] + + def test_extra_fields_forbidden(self) -> None: + """Test that extra fields are not allowed.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ModelResolvedDependencies( + protocols={}, + extra_field="not allowed", # type: ignore[call-arg] + ) diff --git a/tests/unit/services/contract_publisher/test_service.py b/tests/unit/services/contract_publisher/test_service.py index 1db2a1994c..6188c5baea 100644 --- a/tests/unit/services/contract_publisher/test_service.py +++ b/tests/unit/services/contract_publisher/test_service.py @@ -133,7 +133,7 @@ async def test_publish_all_success( mock_publisher.publish.assert_called_once() call_kwargs = mock_publisher.publish.call_args.kwargs assert "topic" in call_kwargs - assert call_kwargs["topic"] == "dev.onex.evt.contract-registered.v1" + assert call_kwargs["topic"] == "dev.onex.evt.platform.contract-registered.v1" @pytest.mark.asyncio async def test_publish_all_multiple_contracts( From b686f99ba662410290b0459fe4a59f9aff722f86 Mon Sep 17 00:00:00 2001 From: Jonah Date: Wed, 4 Feb 2026 13:39:01 -0500 Subject: [PATCH 2/2] fix(OMN-1732): Update topic strings in resolve_topic tests to include platform segment Update test_resolve_topic_* tests to use "onex.evt.platform.contract-registered.v1" instead of "onex.evt.contract-registered.v1" to match TOPIC_SUFFIX_CONTRACT_REGISTERED constant and platform-wide namespace evolution. Addresses CodeRabbit review feedback. --- .../unit/services/contract_publisher/test_service.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/services/contract_publisher/test_service.py b/tests/unit/services/contract_publisher/test_service.py index 6188c5baea..5078affaaa 100644 --- a/tests/unit/services/contract_publisher/test_service.py +++ b/tests/unit/services/contract_publisher/test_service.py @@ -485,8 +485,8 @@ async def test_resolve_topic_default_environment( ) service = ServiceContractPublisher(mock_publisher, mock_source, config) - topic = service.resolve_topic("onex.evt.contract-registered.v1") - assert topic == "dev.onex.evt.contract-registered.v1" + topic = service.resolve_topic("onex.evt.platform.contract-registered.v1") + assert topic == "dev.onex.evt.platform.contract-registered.v1" @pytest.mark.asyncio async def test_resolve_topic_custom_environment( @@ -502,8 +502,8 @@ async def test_resolve_topic_custom_environment( ) service = ServiceContractPublisher(mock_publisher, mock_source, config) - topic = service.resolve_topic("onex.evt.contract-registered.v1") - assert topic == "prod.onex.evt.contract-registered.v1" + topic = service.resolve_topic("onex.evt.platform.contract-registered.v1") + assert topic == "prod.onex.evt.platform.contract-registered.v1" @pytest.mark.asyncio async def test_resolve_topic_env_var( @@ -521,8 +521,8 @@ async def test_resolve_topic_env_var( ) service = ServiceContractPublisher(mock_publisher, mock_source, config) - topic = service.resolve_topic("onex.evt.contract-registered.v1") - assert topic == "staging.onex.evt.contract-registered.v1" + topic = service.resolve_topic("onex.evt.platform.contract-registered.v1") + assert topic == "staging.onex.evt.platform.contract-registered.v1" class TestServiceContractPublisherCreateSource: