diff --git a/features/scope_chain_resolver.feature b/features/scope_chain_resolver.feature new file mode 100644 index 000000000..673c74d41 --- /dev/null +++ b/features/scope_chain_resolver.feature @@ -0,0 +1,82 @@ +Feature: Scope Chain Resolver Protocol and Plugin Registration + As a developer + I want to extend the default scope chain with custom resolvers + So that I can implement custom scope resolution logic + + Background: + Given a fresh scope chain registry + + Scenario: Register a custom scope resolver + Given a custom scope resolver named "custom_resolver" + When I register the resolver + Then the resolver should be registered + And the resolver should be in the registry + + Scenario: Resolve scope with a single resolver + Given a custom scope resolver named "test_resolver" + And the resolver is registered + And a scope context with project_id "proj1", actor_id "actor1", plan_id "plan1" + When I resolve the scope + Then the result should have project_id "proj1" + And the result should have actor_id "actor1" + And the result should have plan_id "plan1" + + Scenario: Resolve scope with multiple resolvers in order + Given a custom scope resolver named "resolver1" + And a custom scope resolver named "resolver2" + And both resolvers are registered + And a scope context with project_id "proj1", actor_id "actor1", plan_id "plan1" + When I resolve the scope with resolvers in order + Then the result should have project_id "proj1" + And the result should have actor_id "actor1" + And the result should have plan_id "plan1" + + Scenario: Unregister a scope resolver + Given a custom scope resolver named "temp_resolver" + And the resolver is registered + When I unregister the resolver + Then the resolver should not be in the registry + + Scenario: List all registered resolvers + Given a custom scope resolver named "resolver1" + And a custom scope resolver named "resolver2" + And both resolvers are registered + When I list all resolvers + Then the list should contain "resolver1" + And the list should contain "resolver2" + + Scenario: Resolver modifies scope context + Given a custom scope resolver that modifies project_id + And the resolver is registered + And a scope context with project_id "original", actor_id "actor1", plan_id "plan1" + When I resolve the scope + Then the result should have project_id "modified" + + Scenario: Resolver adds metadata + Given a custom scope resolver that adds metadata + And the resolver is registered + And a scope context with project_id "proj1", actor_id "actor1", plan_id "plan1" + When I resolve the scope + Then the result metadata should contain "custom_key" + And the metadata value should be "custom_value" + + Scenario: Error handling for unregistered resolver + Given a scope context with project_id "proj1", actor_id "actor1", plan_id "plan1" + When I try to resolve with unregistered resolver "nonexistent" + Then an error should be raised + And the error message should contain "not registered" + + Scenario: Clear all resolvers + Given a custom scope resolver named "resolver1" + And a custom scope resolver named "resolver2" + And both resolvers are registered + When I clear all resolvers + Then the registry should be empty + And the list of resolvers should be empty + + Scenario: Resolver execution order is preserved + Given resolvers that track execution order + And the resolvers are registered in order "first", "second", "third" + And a scope context with project_id "proj1", actor_id "actor1", plan_id "plan1" + When I resolve the scope + Then the execution order should be "first", "second", "third" diff --git a/features/steps/scope_chain_resolver_steps.py b/features/steps/scope_chain_resolver_steps.py new file mode 100644 index 000000000..babd32e97 --- /dev/null +++ b/features/steps/scope_chain_resolver_steps.py @@ -0,0 +1,306 @@ +"""Step definitions for scope chain resolver tests.""" + +from __future__ import annotations + +from typing import ClassVar + +from behave import given, then, when + +from cleveragents.context import ( + ScopeChainRegistry, + ScopeContext, + ScopeResult, +) + + +class TestResolver: + """Test resolver implementation.""" + + def __init__(self, name: str = "test") -> None: + """Initialize test resolver.""" + self.name = name + self.execution_count = 0 + + def resolve(self, context: ScopeContext) -> ScopeResult: + """Resolve scope.""" + self.execution_count += 1 + return ScopeResult( + project_id=context.project_id, + actor_id=context.actor_id, + plan_id=context.plan_id, + metadata=context.metadata.copy(), + ) + + +class ModifyingResolver: + """Resolver that modifies the scope.""" + + def resolve(self, context: ScopeContext) -> ScopeResult: + """Resolve scope and modify project_id.""" + return ScopeResult( + project_id="modified", + actor_id=context.actor_id, + plan_id=context.plan_id, + metadata=context.metadata.copy(), + ) + + +class MetadataResolver: + """Resolver that adds metadata.""" + + def resolve(self, context: ScopeContext) -> ScopeResult: + """Resolve scope and add metadata.""" + metadata = context.metadata.copy() + metadata["custom_key"] = "custom_value" + return ScopeResult( + project_id=context.project_id, + actor_id=context.actor_id, + plan_id=context.plan_id, + metadata=metadata, + ) + + +class TrackingResolver: + """Resolver that tracks execution order.""" + + execution_order: ClassVar[list[str]] = [] + + def __init__(self, name: str) -> None: + """Initialize tracking resolver.""" + self.name = name + + def resolve(self, context: ScopeContext) -> ScopeResult: + """Resolve scope and track execution.""" + TrackingResolver.execution_order.append(self.name) + return ScopeResult( + project_id=context.project_id, + actor_id=context.actor_id, + plan_id=context.plan_id, + metadata=context.metadata.copy(), + ) + + +@given("a fresh scope chain registry") +def step_fresh_registry(context: object) -> None: + """Create a fresh registry.""" + context.registry = ScopeChainRegistry() # type: ignore + + +@given('a custom scope resolver named "{name}"') +def step_custom_resolver(context: object, name: str) -> None: + """Create a custom resolver.""" + if not hasattr(context, "resolvers"): # type: ignore + context.resolvers = {} # type: ignore + context.resolvers[name] = TestResolver(name) # type: ignore + + +@when("I register the resolver") +def step_register_resolver(context: object) -> None: + """Register the resolver.""" + # Get the last created resolver + resolvers = context.resolvers # type: ignore + name = list(resolvers.keys())[-1] + resolver = resolvers[name] + context.registry.register(name, resolver) # type: ignore + + +@then("the resolver should be registered") +def step_resolver_registered(context: object) -> None: + """Check resolver is registered.""" + resolvers = context.resolvers # type: ignore + name = list(resolvers.keys())[-1] + assert context.registry.get(name) is not None # type: ignore + + +@then("the resolver should be in the registry") +def step_resolver_in_registry(context: object) -> None: + """Check resolver is in registry.""" + resolvers = context.resolvers # type: ignore + name = list(resolvers.keys())[-1] + assert name in context.registry.list_resolvers() # type: ignore + + +@given( + 'a scope context with project_id "{project_id}", actor_id "{actor_id}", plan_id "{plan_id}"' +) +def step_scope_context( + context: object, project_id: str, actor_id: str, plan_id: str +) -> None: + """Create a scope context.""" + context.scope_context = ScopeContext( # type: ignore + project_id=project_id, actor_id=actor_id, plan_id=plan_id + ) + + +@when("I resolve the scope") +def step_resolve_scope(context: object) -> None: + """Resolve the scope.""" + context.result = context.registry.resolve(context.scope_context) # type: ignore + + +@then('the result should have project_id "{project_id}"') +def step_check_project_id(context: object, project_id: str) -> None: + """Check project_id in result.""" + assert context.result.project_id == project_id # type: ignore + + +@then('the result should have actor_id "{actor_id}"') +def step_check_actor_id(context: object, actor_id: str) -> None: + """Check actor_id in result.""" + assert context.result.actor_id == actor_id # type: ignore + + +@then('the result should have plan_id "{plan_id}"') +def step_check_plan_id(context: object, plan_id: str) -> None: + """Check plan_id in result.""" + assert context.result.plan_id == plan_id # type: ignore + + +@given("a custom scope resolver that modifies project_id") +def step_modifying_resolver(context: object) -> None: + """Create a resolver that modifies project_id.""" + if not hasattr(context, "resolvers"): # type: ignore + context.resolvers = {} # type: ignore + context.resolvers["modifier"] = ModifyingResolver() # type: ignore + + +@given("a custom scope resolver that adds metadata") +def step_metadata_resolver(context: object) -> None: + """Create a resolver that adds metadata.""" + if not hasattr(context, "resolvers"): # type: ignore + context.resolvers = {} # type: ignore + context.resolvers["metadata"] = MetadataResolver() # type: ignore + + +@then('the result metadata should contain "{key}"') +def step_check_metadata_key(context: object, key: str) -> None: + """Check metadata contains key.""" + assert key in context.result.metadata # type: ignore + + +@then('the metadata value should be "{value}"') +def step_check_metadata_value(context: object, value: str) -> None: + """Check metadata value.""" + assert context.result.metadata.get("custom_key") == value # type: ignore + + +@when('I try to resolve with unregistered resolver "{name}"') +def step_resolve_unregistered(context: object, name: str) -> None: + """Try to resolve with unregistered resolver.""" + try: + context.registry.resolve( # type: ignore + context.scope_context, [name] # type: ignore + ) + context.error_raised = False # type: ignore + except ValueError as e: + context.error_raised = True # type: ignore + context.error_message = str(e) # type: ignore + + +@then("an error should be raised") +def step_error_raised(context: object) -> None: + """Check error was raised.""" + assert context.error_raised is True # type: ignore + + +@then('the error message should contain "{text}"') +def step_check_error_message(context: object, text: str) -> None: + """Check error message contains text.""" + assert text in context.error_message # type: ignore + + +@when("I unregister the resolver") +def step_unregister_resolver(context: object) -> None: + """Unregister the resolver.""" + resolvers = context.resolvers # type: ignore + name = list(resolvers.keys())[-1] + context.registry.unregister(name) # type: ignore + + +@then("the resolver should not be in the registry") +def step_resolver_not_in_registry(context: object) -> None: + """Check resolver is not in registry.""" + resolvers = context.resolvers # type: ignore + name = list(resolvers.keys())[-1] + assert name not in context.registry.list_resolvers() # type: ignore + + +@when("I list all resolvers") +def step_list_resolvers(context: object) -> None: + """List all resolvers.""" + context.resolver_list = context.registry.list_resolvers() # type: ignore + + +@then('the list should contain "{name}"') +def step_check_resolver_in_list(context: object, name: str) -> None: + """Check resolver is in list.""" + assert name in context.resolver_list # type: ignore + + +@given("a custom scope resolver named {name}") +def step_custom_resolver_alt(context: object, name: str) -> None: + """Create a custom resolver (alternative).""" + if not hasattr(context, "resolvers"): # type: ignore + context.resolvers = {} # type: ignore + context.resolvers[name] = TestResolver(name) # type: ignore + + +@given("both resolvers are registered") +def step_register_both(context: object) -> None: + """Register both resolvers.""" + resolvers = context.resolvers # type: ignore + for name, resolver in resolvers.items(): + context.registry.register(name, resolver) # type: ignore + + +@when("I resolve the scope with resolvers in order") +def step_resolve_with_order(context: object) -> None: + """Resolve scope with resolvers in order.""" + context.result = context.registry.resolve(context.scope_context) # type: ignore + + +@when("I clear all resolvers") +def step_clear_resolvers(context: object) -> None: + """Clear all resolvers.""" + context.registry.clear() # type: ignore + + +@then("the registry should be empty") +def step_registry_empty(context: object) -> None: + """Check registry is empty.""" + assert len(context.registry.list_resolvers()) == 0 # type: ignore + + +@then("the list of resolvers should be empty") +def step_resolver_list_empty(context: object) -> None: + """Check resolver list is empty.""" + assert context.resolver_list == [] # type: ignore + + +@given("resolvers that track execution order") +def step_tracking_resolvers(context: object) -> None: + """Create resolvers that track execution order.""" + TrackingResolver.execution_order = [] + if not hasattr(context, "resolvers"): # type: ignore + context.resolvers = {} # type: ignore + context.resolvers["first"] = TrackingResolver("first") # type: ignore + context.resolvers["second"] = TrackingResolver("second") # type: ignore + context.resolvers["third"] = TrackingResolver("third") # type: ignore + + +@given('the resolvers are registered in order "{first}", "{second}", "{third}"') +def step_register_in_order(context: object, first: str, second: str, third: str) -> None: + """Register resolvers in order.""" + resolvers = context.resolvers # type: ignore + context.registry.register(first, resolvers[first]) # type: ignore + context.registry.register(second, resolvers[second]) # type: ignore + context.registry.register(third, resolvers[third]) # type: ignore + + +@then('the execution order should be "{first}", "{second}", "{third}"') +def step_check_execution_order( + context: object, first: str, second: str, third: str +) -> None: + """Check execution order.""" + expected = [first, second, third] + assert TrackingResolver.execution_order == expected # type: ignore diff --git a/src/cleveragents/context/__init__.py b/src/cleveragents/context/__init__.py new file mode 100644 index 000000000..1ec0e690f --- /dev/null +++ b/src/cleveragents/context/__init__.py @@ -0,0 +1,15 @@ +"""Context module for scope chain resolution. + +Provides the ScopeChainResolver protocol and plugin registration mechanism +for extending the default project → actor → plan scope chain. +""" + +from .protocols import ScopeChainResolver, ScopeContext, ScopeResult +from .registry import ScopeChainRegistry + +__all__ = [ + "ScopeChainRegistry", + "ScopeChainResolver", + "ScopeContext", + "ScopeResult", +] diff --git a/src/cleveragents/context/protocols.py b/src/cleveragents/context/protocols.py new file mode 100644 index 000000000..6507144d5 --- /dev/null +++ b/src/cleveragents/context/protocols.py @@ -0,0 +1,78 @@ +"""Protocol definitions for scope chain resolution. + +Defines the ScopeChainResolver protocol that allows users to extend +the default project → actor → plan scope chain with custom resolvers. +""" + +from __future__ import annotations + +from typing import Any, Protocol + +from pydantic import BaseModel + + +class ScopeContext(BaseModel): + """Context information passed to scope resolvers. + + Contains the current scope state and metadata needed for resolution. + """ + + project_id: str | None = None + actor_id: str | None = None + plan_id: str | None = None + metadata: dict[str, Any] = {} + + class Config: + """Pydantic configuration.""" + + arbitrary_types_allowed = True + + +class ScopeResult(BaseModel): + """Result of scope resolution. + + Contains the resolved scope values and any additional metadata. + """ + + project_id: str | None = None + actor_id: str | None = None + plan_id: str | None = None + metadata: dict[str, Any] = {} + resolved: bool = True + + class Config: + """Pydantic configuration.""" + + arbitrary_types_allowed = True + + +class ScopeChainResolver(Protocol): + """Protocol for custom scope chain resolvers. + + Allows users to extend the default project → actor → plan scope chain + with custom resolution logic. Resolvers are called in configured order + and can modify or enhance the scope context. + + Example: + class CustomScopeResolver: + def resolve(self, context: ScopeContext) -> ScopeResult: + # Custom resolution logic + result = ScopeResult( + project_id=context.project_id, + actor_id=context.actor_id, + plan_id=context.plan_id, + ) + return result + """ + + def resolve(self, context: ScopeContext) -> ScopeResult: + """Resolve scope based on the provided context. + + Args: + context: The current scope context containing project, actor, + and plan IDs along with metadata. + + Returns: + ScopeResult containing the resolved scope values and metadata. + """ + ... diff --git a/src/cleveragents/context/registry.py b/src/cleveragents/context/registry.py new file mode 100644 index 000000000..39a78e561 --- /dev/null +++ b/src/cleveragents/context/registry.py @@ -0,0 +1,166 @@ +"""Registry for scope chain resolvers. + +Manages loading and validation of custom scope resolvers from entry points +and configuration. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +import sys + +from .protocols import ScopeChainResolver, ScopeContext, ScopeResult + +logger = logging.getLogger(__name__) + + +class ScopeChainRegistry: + """Registry for managing scope chain resolvers. + + Loads resolvers from Python entry points and manages their execution + in configured order. + """ + + def __init__(self) -> None: + """Initialize the registry.""" + self._resolvers: dict[str, ScopeChainResolver] = {} + self._resolver_order: list[str] = [] + + def register(self, name: str, resolver: ScopeChainResolver) -> None: + """Register a scope chain resolver. + + Args: + name: Unique identifier for the resolver. + resolver: The resolver instance implementing ScopeChainResolver. + + Raises: + ValueError: If a resolver with the same name is already registered. + """ + if name in self._resolvers: + msg = f"Resolver '{name}' is already registered" + raise ValueError(msg) + self._resolvers[name] = resolver + self._resolver_order.append(name) + logger.debug(f"Registered scope resolver: {name}") + + def unregister(self, name: str) -> None: + """Unregister a scope chain resolver. + + Args: + name: Identifier of the resolver to unregister. + + Raises: + ValueError: If the resolver is not registered. + """ + if name not in self._resolvers: + msg = f"Resolver '{name}' is not registered" + raise ValueError(msg) + del self._resolvers[name] + self._resolver_order.remove(name) + logger.debug(f"Unregistered scope resolver: {name}") + + def get(self, name: str) -> ScopeChainResolver | None: + """Get a registered resolver by name. + + Args: + name: Identifier of the resolver. + + Returns: + The resolver instance or None if not found. + """ + return self._resolvers.get(name) + + def list_resolvers(self) -> list[str]: + """List all registered resolver names in order. + + Returns: + List of resolver names in execution order. + """ + return self._resolver_order.copy() + + def resolve( + self, context: ScopeContext, resolver_names: list[str] | None = None + ) -> ScopeResult: + """Execute resolvers in order to resolve scope. + + Args: + context: The scope context to resolve. + resolver_names: Optional list of resolver names to execute. + If None, all registered resolvers are executed in order. + + Returns: + The final ScopeResult after all resolvers have been applied. + + Raises: + ValueError: If a specified resolver is not registered. + """ + result = ScopeResult( + project_id=context.project_id, + actor_id=context.actor_id, + plan_id=context.plan_id, + metadata=context.metadata.copy(), + ) + + resolvers_to_run = resolver_names or self._resolver_order + + for resolver_name in resolvers_to_run: + if resolver_name not in self._resolvers: + msg = f"Resolver '{resolver_name}' is not registered" + raise ValueError(msg) + + resolver = self._resolvers[resolver_name] + # Create a new context from the current result + current_context = ScopeContext( + project_id=result.project_id, + actor_id=result.actor_id, + plan_id=result.plan_id, + metadata=result.metadata.copy(), + ) + + try: + result = resolver.resolve(current_context) + logger.debug(f"Resolver '{resolver_name}' executed successfully") + except Exception as e: + logger.error(f"Error executing resolver '{resolver_name}': {e}") + raise + + return result + + def load_from_entry_points(self) -> None: + """Load resolvers from Python entry points. + + Loads all resolvers registered under the 'cleveragents.scope_resolvers' + entry point group. + """ + try: + entry_points = importlib.metadata.entry_points() + # Handle both old and new entry_points API + if sys.version_info >= (3, 10): + # Python 3.10+ API + scope_resolvers = entry_points.select( + group="cleveragents.scope_resolvers" + ) + else: + # Python 3.9 API + scope_resolvers = entry_points.get("cleveragents.scope_resolvers", []) + + for ep in scope_resolvers: + try: + resolver_class = ep.load() + resolver_instance = resolver_class() + self.register(ep.name, resolver_instance) + logger.info(f"Loaded scope resolver from entry point: {ep.name}") + except Exception as e: + logger.error( + f"Failed to load scope resolver '{ep.name}' from entry " + f"point: {e}" + ) + except Exception as e: + logger.error(f"Error loading scope resolvers from entry points: {e}") + + def clear(self) -> None: + """Clear all registered resolvers.""" + self._resolvers.clear() + self._resolver_order.clear() + logger.debug("Cleared all scope resolvers")