feat(context): implement ScopeChainResolver protocol and plugin registration mechanism #10620

Closed
HAL9000 wants to merge 4 commits from feat/v3.6.0/pluggable-scope-chain into master
5 changed files with 647 additions and 0 deletions
+82
View File
@@ -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"
@@ -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
+15
View File
@@ -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",
]
+78
View File
@@ -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.
"""
...
+166
View File
@@ -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")