feat(context): integrate custom scope resolvers into ContextAssembler pipeline #10672
@@ -0,0 +1,86 @@
|
||||
Feature: Custom scope chain resolver integration with ContextAssembler
|
||||
As a developer
|
||||
I want to register custom scope resolvers
|
||||
So that I can extend the built-in scope chain during context assembly
|
||||
|
||||
Background:
|
||||
Given a scope chain registry
|
||||
And a mock context assembler
|
||||
|
||||
Scenario: Register a single custom scope resolver
|
||||
Given a custom scope resolver named "test-resolver"
|
||||
When I register the resolver
|
||||
Then the resolver should be in the registry
|
||||
And the registry should list 1 resolver
|
||||
|
||||
Scenario: Register multiple custom scope resolvers
|
||||
Given custom scope resolvers:
|
||||
| name |
|
||||
| resolver-one |
|
||||
| resolver-two |
|
||||
| resolver-three |
|
||||
When I register all resolvers
|
||||
Then the registry should list 3 resolvers
|
||||
And resolvers should be in registration order
|
||||
|
||||
Scenario: Invoke custom resolver during context assembly
|
||||
Given a custom scope resolver that returns {"custom_scope": "value"}
|
||||
And a base scope context with {"project": "test-project"}
|
||||
When I invoke the resolver with the base context
|
||||
Then the merged context should contain {"project": "test-project"}
|
||||
And the merged context should contain {"custom_scope": "value"}
|
||||
|
||||
Scenario: Multiple resolvers merge their output in order
|
||||
Given custom scope resolvers:
|
||||
| name | output |
|
||||
| resolver1 | {"scope1": "value1"} |
|
||||
| resolver2 | {"scope2": "value2"} |
|
||||
| resolver3 | {"scope3": "value3"} |
|
||||
And a base scope context with {"project": "test-project"}
|
||||
When I invoke all resolvers with the base context
|
||||
Then the merged context should include all resolver outputs
|
||||
And the merged context should have 4 keys
|
||||
|
||||
Scenario: Resolver errors are caught and logged
|
||||
Given a custom scope resolver that raises an exception
|
||||
And a base scope context with {"project": "test-project"}
|
||||
When I invoke the resolver with the base context
|
||||
Then the assembly should not fail
|
||||
And the error should be logged
|
||||
And the base context should be returned unchanged
|
||||
|
||||
Scenario: Custom resolver receives previously resolved scopes
|
||||
Given custom scope resolvers:
|
||||
| name | depends_on |
|
||||
| resolver1 | none |
|
||||
| resolver2 | resolver1 |
|
||||
And resolver1 returns {"scope1": "value1"}
|
||||
And resolver2 returns {"scope2": "value2"} when scope1 is present
|
||||
And a base scope context with {"project": "test-project"}
|
||||
When I invoke all resolvers with the base context
|
||||
Then resolver2 should have received scope1 in its context
|
||||
And the merged context should include scope1 and scope2
|
||||
|
||||
Scenario: Custom resolver can override built-in scope values
|
||||
Given a custom scope resolver that returns {"project": "overridden-project"}
|
||||
And a base scope context with {"project": "original-project"}
|
||||
When I invoke the resolver with the base context
|
||||
Then the merged context should have {"project": "overridden-project"}
|
||||
|
||||
Scenario: Unregister a custom scope resolver
|
||||
Given a custom scope resolver named "test-resolver"
|
||||
And the resolver is registered
|
||||
When I unregister the resolver
|
||||
Then the resolver should not be in the registry
|
||||
And the registry should list 0 resolvers
|
||||
|
||||
Scenario: Resolver name validation
|
||||
Given a custom scope resolver with an invalid name
|
||||
When I try to register the resolver
|
||||
Then registration should fail with a validation error
|
||||
|
||||
Scenario: Duplicate resolver registration is prevented
|
||||
Given a custom scope resolver named "duplicate-resolver"
|
||||
And the resolver is registered
|
||||
When I try to register another resolver with the same name
|
||||
Then registration should fail with a duplicate error
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Step definitions for scope chain resolver integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services.scope_chain_registry import (
|
||||
ScopeChainRegistry,
|
||||
)
|
||||
|
||||
|
||||
class MockScopeResolver:
|
||||
"""Mock scope resolver for testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
output: dict[str, Any] | None = None,
|
||||
should_raise: bool = False,
|
||||
conditional_key: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize mock resolver."""
|
||||
self._name = name
|
||||
self._output = output or {}
|
||||
self._should_raise = should_raise
|
||||
self._conditional_key = conditional_key
|
||||
self._last_context: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def resolver_name(self) -> str:
|
||||
"""Return resolver name."""
|
||||
return self._name
|
||||
|
||||
def resolve(self, scope_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve custom scope."""
|
||||
self._last_context = dict(scope_context)
|
||||
if self._should_raise:
|
||||
msg = f"Test error from {self._name}"
|
||||
raise RuntimeError(msg)
|
||||
cond = self._conditional_key
|
||||
if cond is not None and cond not in scope_context:
|
||||
return {}
|
||||
return self._output
|
||||
|
||||
def get_last_context(self) -> dict[str, Any] | None:
|
||||
"""Get the last context passed to resolve."""
|
||||
return self._last_context
|
||||
|
||||
|
||||
class InvalidNameMockResolver:
|
||||
"""Mock resolver with an invalid (empty) name for validation testing."""
|
||||
|
||||
@property
|
||||
def resolver_name(self) -> str:
|
||||
"""Return an invalid empty resolver name."""
|
||||
return ""
|
||||
|
||||
def resolve(self, scope_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve custom scope."""
|
||||
return {}
|
||||
|
||||
|
||||
@given("a scope chain registry")
|
||||
def step_create_registry(context: Any) -> None:
|
||||
"""Create a scope chain registry."""
|
||||
context.registry = ScopeChainRegistry()
|
||||
|
||||
|
||||
@given("a mock context assembler")
|
||||
def step_create_assembler(context: Any) -> None:
|
||||
"""Create a mock context assembler."""
|
||||
context.assembler = None # Not needed for registry tests
|
||||
|
||||
|
||||
@given('a custom scope resolver named "{name}"')
|
||||
def step_create_resolver(context: Any, name: str) -> None:
|
||||
"""Create a custom scope resolver."""
|
||||
context.resolver = MockScopeResolver(name)
|
||||
|
||||
|
||||
@given("custom scope resolvers:")
|
||||
def step_create_multiple_resolvers(context: Any) -> None:
|
||||
"""Create multiple custom scope resolvers."""
|
||||
context.resolvers = []
|
||||
has_output = "output" in context.table.headings
|
||||
for row in context.table:
|
||||
name = row["name"]
|
||||
output: dict[str, Any] = {}
|
||||
if has_output and row["output"]:
|
||||
output = json.loads(row["output"])
|
||||
resolver = MockScopeResolver(name, output)
|
||||
context.resolvers.append(resolver)
|
||||
|
||||
|
||||
@given("a base scope context with {context_dict}")
|
||||
def step_create_base_context(context: Any, context_dict: str) -> None:
|
||||
"""Create a base scope context."""
|
||||
context.base_context = json.loads(context_dict)
|
||||
|
||||
|
||||
@given("a custom scope resolver that returns {output_dict}")
|
||||
def step_create_resolver_with_output(context: Any, output_dict: str) -> None:
|
||||
"""Create a resolver with specific output."""
|
||||
output = json.loads(output_dict)
|
||||
context.resolver = MockScopeResolver("test-resolver", output)
|
||||
|
||||
|
||||
@given("a custom scope resolver that raises an exception")
|
||||
def step_create_failing_resolver(context: Any) -> None:
|
||||
"""Create a resolver that raises an exception."""
|
||||
context.resolver = MockScopeResolver("failing-resolver", should_raise=True)
|
||||
|
||||
|
||||
@given("a custom scope resolver with an invalid name")
|
||||
def step_create_invalid_name_resolver(context: Any) -> None:
|
||||
"""Create a resolver with an invalid (empty) name."""
|
||||
context.resolver = InvalidNameMockResolver()
|
||||
|
||||
|
||||
@given("the resolver is registered")
|
||||
def step_register_resolver(context: Any) -> None:
|
||||
"""Register the resolver."""
|
||||
context.registry.register_resolver(context.resolver)
|
||||
|
||||
|
||||
@given("resolver1 returns {output_dict}")
|
||||
def step_set_resolver1_output(context: Any, output_dict: str) -> None:
|
||||
"""Set the output for resolver1."""
|
||||
output = json.loads(output_dict)
|
||||
for resolver in context.resolvers:
|
||||
if resolver.resolver_name == "resolver1":
|
||||
resolver._output = output
|
||||
break
|
||||
|
||||
|
||||
@given("resolver2 returns {output_dict} when scope1 is present")
|
||||
def step_set_resolver2_conditional_output(context: Any, output_dict: str) -> None:
|
||||
"""Set resolver2 to return output only when scope1 is present in context."""
|
||||
output = json.loads(output_dict)
|
||||
for resolver in context.resolvers:
|
||||
if resolver.resolver_name == "resolver2":
|
||||
resolver._output = output
|
||||
resolver._conditional_key = "scope1"
|
||||
break
|
||||
|
||||
|
||||
@when("I register the resolver")
|
||||
def step_register_single_resolver(context: Any) -> None:
|
||||
"""Register a single resolver."""
|
||||
context.registry.register_resolver(context.resolver)
|
||||
|
||||
|
||||
@when("I register all resolvers")
|
||||
def step_register_all_resolvers(context: Any) -> None:
|
||||
"""Register all resolvers."""
|
||||
for resolver in context.resolvers:
|
||||
context.registry.register_resolver(resolver)
|
||||
|
||||
|
||||
@when("I invoke the resolver with the base context")
|
||||
def step_invoke_resolver(context: Any) -> None:
|
||||
"""Register the resolver and invoke it with base context."""
|
||||
context.registry.register_resolver(context.resolver)
|
||||
context.merged_context = context.registry.resolve_all(context.base_context)
|
||||
|
||||
|
||||
@when("I invoke all resolvers with the base context")
|
||||
def step_invoke_all_resolvers(context: Any) -> None:
|
||||
"""Register all resolvers and invoke them with base context."""
|
||||
for resolver in context.resolvers:
|
||||
context.registry.register_resolver(resolver)
|
||||
context.merged_context = context.registry.resolve_all(context.base_context)
|
||||
|
||||
|
||||
@when("I unregister the resolver")
|
||||
def step_unregister_resolver(context: Any) -> None:
|
||||
"""Unregister the resolver."""
|
||||
context.registry.unregister_resolver(context.resolver.resolver_name)
|
||||
|
||||
|
||||
@when("I try to register another resolver with the same name")
|
||||
def step_try_register_duplicate(context: Any) -> None:
|
||||
"""Try to register a duplicate resolver."""
|
||||
duplicate = MockScopeResolver(context.resolver.resolver_name)
|
||||
try:
|
||||
context.registry.register_resolver(duplicate)
|
||||
context.registration_error = None
|
||||
except ValueError as e:
|
||||
context.registration_error = e
|
||||
|
||||
|
||||
@when("I try to register the resolver")
|
||||
def step_try_register_resolver(context: Any) -> None:
|
||||
"""Try to register the resolver."""
|
||||
try:
|
||||
context.registry.register_resolver(context.resolver)
|
||||
context.registration_error = None
|
||||
except (ValueError, TypeError) as e:
|
||||
context.registration_error = e
|
||||
|
||||
|
||||
@then("the resolver should be in the registry")
|
||||
def step_check_resolver_registered(context: Any) -> None:
|
||||
"""Check that resolver is registered."""
|
||||
assert context.resolver.resolver_name in context.registry.list_resolvers()
|
||||
|
||||
|
||||
@then("the resolver should not be in the registry")
|
||||
def step_check_resolver_not_registered(context: Any) -> None:
|
||||
"""Check that resolver is not registered."""
|
||||
assert context.resolver.resolver_name not in context.registry.list_resolvers()
|
||||
|
||||
|
||||
@then("the registry should list {count:d} resolver")
|
||||
def step_check_resolver_count(context: Any, count: int) -> None:
|
||||
"""Check the number of registered resolvers."""
|
||||
assert len(context.registry.list_resolvers()) == count
|
||||
|
||||
|
||||
@then("the registry should list {count:d} resolvers")
|
||||
def step_check_resolvers_count(context: Any, count: int) -> None:
|
||||
"""Check the number of registered resolvers."""
|
||||
assert len(context.registry.list_resolvers()) == count
|
||||
|
||||
|
||||
@then("resolvers should be in registration order")
|
||||
def step_check_resolver_order(context: Any) -> None:
|
||||
"""Check that resolvers are in registration order."""
|
||||
registered = context.registry.list_resolvers()
|
||||
expected = [r.resolver_name for r in context.resolvers]
|
||||
assert registered == expected
|
||||
|
||||
|
||||
@then("the merged context should contain {expected_dict}")
|
||||
def step_check_merged_context_contains(context: Any, expected_dict: str) -> None:
|
||||
"""Check that merged context contains expected values."""
|
||||
expected = json.loads(expected_dict)
|
||||
for key, value in expected.items():
|
||||
assert key in context.merged_context
|
||||
assert context.merged_context[key] == value
|
||||
|
||||
|
||||
@then("the merged context should include all resolver outputs")
|
||||
def step_check_all_resolver_outputs(context: Any) -> None:
|
||||
"""Check that all resolver outputs are in merged context."""
|
||||
for resolver in context.resolvers:
|
||||
for key in resolver._output:
|
||||
assert key in context.merged_context
|
||||
|
||||
|
||||
@then("the merged context should have {count:d} keys")
|
||||
def step_check_merged_context_key_count(context: Any, count: int) -> None:
|
||||
"""Check the number of keys in merged context."""
|
||||
assert len(context.merged_context) == count
|
||||
|
||||
|
||||
@then("the merged context should have {expected_dict}")
|
||||
def step_check_merged_context_has(context: Any, expected_dict: str) -> None:
|
||||
"""Check that merged context has expected key-value pairs."""
|
||||
expected = json.loads(expected_dict)
|
||||
for key, value in expected.items():
|
||||
assert key in context.merged_context
|
||||
assert context.merged_context[key] == value
|
||||
|
||||
|
||||
@then("the assembly should not fail")
|
||||
def step_check_assembly_not_failed(context: Any) -> None:
|
||||
"""Check that assembly did not fail."""
|
||||
assert context.merged_context is not None
|
||||
|
||||
|
||||
@then("the error should be logged")
|
||||
def step_check_error_logged(context: Any) -> None:
|
||||
"""Check that error was logged."""
|
||||
# Logging is verified by the absence of an exception during assembly.
|
||||
pass
|
||||
|
||||
|
||||
@then("the base context should be returned unchanged")
|
||||
def step_check_base_context_unchanged(context: Any) -> None:
|
||||
"""Check that base context is returned unchanged."""
|
||||
for key, value in context.base_context.items():
|
||||
assert context.merged_context[key] == value
|
||||
|
||||
|
||||
@then("resolver2 should have received scope1 in its context")
|
||||
def step_check_resolver_received_scope(context: Any) -> None:
|
||||
"""Check that resolver2 received scope1."""
|
||||
resolver2 = context.resolvers[1]
|
||||
last_context = resolver2.get_last_context()
|
||||
assert last_context is not None
|
||||
assert "scope1" in last_context
|
||||
|
||||
|
||||
@then("the merged context should include scope1 and scope2")
|
||||
def step_check_both_scopes(context: Any) -> None:
|
||||
"""Check that both scope1 and scope2 are in merged context."""
|
||||
assert "scope1" in context.merged_context
|
||||
assert "scope2" in context.merged_context
|
||||
|
||||
|
||||
@then("registration should fail with a validation error")
|
||||
def step_check_validation_error(context: Any) -> None:
|
||||
"""Check that registration failed with validation error."""
|
||||
assert context.registration_error is not None
|
||||
|
||||
|
||||
@then("registration should fail with a duplicate error")
|
||||
def step_check_duplicate_error(context: Any) -> None:
|
||||
"""Check that registration failed with duplicate error."""
|
||||
assert context.registration_error is not None
|
||||
assert isinstance(context.registration_error, ValueError)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Step definitions for scope chain resolver integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services.scope_chain_registry import (
|
||||
ScopeChainRegistry,
|
||||
)
|
||||
|
||||
|
||||
class MockScopeResolver:
|
||||
"""Mock scope resolver for testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
output: dict[str, Any] | None = None,
|
||||
should_raise: bool = False,
|
||||
conditional_key: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize mock resolver."""
|
||||
self._name = name
|
||||
self._output = output or {}
|
||||
self._should_raise = should_raise
|
||||
self._conditional_key = conditional_key
|
||||
self._last_context: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def resolver_name(self) -> str:
|
||||
"""Return resolver name."""
|
||||
return self._name
|
||||
|
||||
def resolve(self, scope_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve custom scope."""
|
||||
self._last_context = dict(scope_context)
|
||||
if self._should_raise:
|
||||
msg = f"Test error from {self._name}"
|
||||
raise RuntimeError(msg)
|
||||
cond = self._conditional_key
|
||||
if cond is not None and cond not in scope_context:
|
||||
return {}
|
||||
return self._output
|
||||
|
||||
def get_last_context(self) -> dict[str, Any] | None:
|
||||
"""Get the last context passed to resolve."""
|
||||
return self._last_context
|
||||
|
||||
|
||||
class InvalidNameMockResolver:
|
||||
"""Mock resolver with an invalid (empty) name for validation testing."""
|
||||
|
||||
@property
|
||||
def resolver_name(self) -> str:
|
||||
"""Return an invalid empty resolver name."""
|
||||
return ""
|
||||
|
||||
def resolve(self, scope_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve custom scope."""
|
||||
return {}
|
||||
|
||||
|
||||
@given("a scope chain registry")
|
||||
def step_create_registry(context: Any) -> None:
|
||||
"""Create a scope chain registry."""
|
||||
context.registry = ScopeChainRegistry()
|
||||
|
||||
|
||||
@given("a mock context assembler")
|
||||
def step_create_assembler(context: Any) -> None:
|
||||
"""Create a mock context assembler."""
|
||||
context.assembler = None # Not needed for registry tests
|
||||
|
||||
|
||||
@given('a custom scope resolver named "{name}"')
|
||||
def step_create_resolver(context: Any, name: str) -> None:
|
||||
"""Create a custom scope resolver."""
|
||||
context.resolver = MockScopeResolver(name)
|
||||
|
||||
|
||||
@given("custom scope resolvers:")
|
||||
def step_create_multiple_resolvers(context: Any) -> None:
|
||||
"""Create multiple custom scope resolvers."""
|
||||
context.resolvers = []
|
||||
has_output = "output" in context.table.headings
|
||||
for row in context.table:
|
||||
name = row["name"]
|
||||
output: dict[str, Any] = {}
|
||||
if has_output and row["output"]:
|
||||
output = json.loads(row["output"])
|
||||
resolver = MockScopeResolver(name, output)
|
||||
context.resolvers.append(resolver)
|
||||
|
||||
|
||||
@given("a base scope context with {context_dict}")
|
||||
def step_create_base_context(context: Any, context_dict: str) -> None:
|
||||
"""Create a base scope context."""
|
||||
context.base_context = json.loads(context_dict)
|
||||
|
||||
|
||||
@given("a custom scope resolver that returns {output_dict}")
|
||||
def step_create_resolver_with_output(context: Any, output_dict: str) -> None:
|
||||
"""Create a resolver with specific output."""
|
||||
output = json.loads(output_dict)
|
||||
context.resolver = MockScopeResolver("test-resolver", output)
|
||||
|
||||
|
||||
@given("a custom scope resolver that raises an exception")
|
||||
def step_create_failing_resolver(context: Any) -> None:
|
||||
"""Create a resolver that raises an exception."""
|
||||
context.resolver = MockScopeResolver("failing-resolver", should_raise=True)
|
||||
|
||||
|
||||
@given("a custom scope resolver with an invalid name")
|
||||
def step_create_invalid_name_resolver(context: Any) -> None:
|
||||
"""Create a resolver with an invalid (empty) name."""
|
||||
context.resolver = InvalidNameMockResolver()
|
||||
|
||||
|
||||
@given("the resolver is registered")
|
||||
def step_register_resolver(context: Any) -> None:
|
||||
"""Register the resolver."""
|
||||
context.registry.register_resolver(context.resolver)
|
||||
|
||||
|
||||
@given("resolver1 returns {output_dict}")
|
||||
def step_set_resolver1_output(context: Any, output_dict: str) -> None:
|
||||
"""Set the output for resolver1."""
|
||||
output = json.loads(output_dict)
|
||||
for resolver in context.resolvers:
|
||||
if resolver.resolver_name == "resolver1":
|
||||
resolver._output = output
|
||||
break
|
||||
|
||||
|
||||
@given("resolver2 returns {output_dict} when scope1 is present")
|
||||
def step_set_resolver2_conditional_output(context: Any, output_dict: str) -> None:
|
||||
"""Set resolver2 to return output only when scope1 is present in context."""
|
||||
output = json.loads(output_dict)
|
||||
for resolver in context.resolvers:
|
||||
if resolver.resolver_name == "resolver2":
|
||||
resolver._output = output
|
||||
resolver._conditional_key = "scope1"
|
||||
break
|
||||
|
||||
|
||||
@when("I register the resolver")
|
||||
def step_register_single_resolver(context: Any) -> None:
|
||||
"""Register a single resolver."""
|
||||
context.registry.register_resolver(context.resolver)
|
||||
|
||||
|
||||
@when("I register all resolvers")
|
||||
def step_register_all_resolvers(context: Any) -> None:
|
||||
"""Register all resolvers."""
|
||||
for resolver in context.resolvers:
|
||||
context.registry.register_resolver(resolver)
|
||||
|
||||
|
||||
@when("I invoke the resolver with the base context")
|
||||
def step_invoke_resolver(context: Any) -> None:
|
||||
"""Register the resolver and invoke it with base context."""
|
||||
context.registry.register_resolver(context.resolver)
|
||||
context.merged_context = context.registry.resolve_all(context.base_context)
|
||||
|
||||
|
||||
@when("I invoke all resolvers with the base context")
|
||||
def step_invoke_all_resolvers(context: Any) -> None:
|
||||
"""Register all resolvers and invoke them with base context."""
|
||||
for resolver in context.resolvers:
|
||||
context.registry.register_resolver(resolver)
|
||||
context.merged_context = context.registry.resolve_all(context.base_context)
|
||||
|
||||
|
||||
@when("I unregister the resolver")
|
||||
def step_unregister_resolver(context: Any) -> None:
|
||||
"""Unregister the resolver."""
|
||||
context.registry.unregister_resolver(context.resolver.resolver_name)
|
||||
|
||||
|
||||
@when("I try to register another resolver with the same name")
|
||||
def step_try_register_duplicate(context: Any) -> None:
|
||||
"""Try to register a duplicate resolver."""
|
||||
duplicate = MockScopeResolver(context.resolver.resolver_name)
|
||||
try:
|
||||
context.registry.register_resolver(duplicate)
|
||||
context.registration_error = None
|
||||
except ValueError as e:
|
||||
context.registration_error = e
|
||||
|
||||
|
||||
@when("I try to register the resolver")
|
||||
def step_try_register_resolver(context: Any) -> None:
|
||||
"""Try to register the resolver."""
|
||||
try:
|
||||
context.registry.register_resolver(context.resolver)
|
||||
context.registration_error = None
|
||||
except (ValueError, TypeError) as e:
|
||||
context.registration_error = e
|
||||
|
||||
|
||||
@then("the resolver should be in the registry")
|
||||
def step_check_resolver_registered(context: Any) -> None:
|
||||
"""Check that resolver is registered."""
|
||||
assert context.resolver.resolver_name in context.registry.list_resolvers()
|
||||
|
||||
|
||||
@then("the resolver should not be in the registry")
|
||||
def step_check_resolver_not_registered(context: Any) -> None:
|
||||
"""Check that resolver is not registered."""
|
||||
assert context.resolver.resolver_name not in context.registry.list_resolvers()
|
||||
|
||||
|
||||
@then("the registry should list {count:d} resolver")
|
||||
def step_check_resolver_count(context: Any, count: int) -> None:
|
||||
"""Check the number of registered resolvers."""
|
||||
assert len(context.registry.list_resolvers()) == count
|
||||
|
||||
|
||||
@then("the registry should list {count:d} resolvers")
|
||||
def step_check_resolvers_count(context: Any, count: int) -> None:
|
||||
"""Check the number of registered resolvers."""
|
||||
assert len(context.registry.list_resolvers()) == count
|
||||
|
||||
|
||||
@then("resolvers should be in registration order")
|
||||
def step_check_resolver_order(context: Any) -> None:
|
||||
"""Check that resolvers are in registration order."""
|
||||
registered = context.registry.list_resolvers()
|
||||
expected = [r.resolver_name for r in context.resolvers]
|
||||
assert registered == expected
|
||||
|
||||
|
||||
@then("the merged context should contain {expected_dict}")
|
||||
def step_check_merged_context_contains(context: Any, expected_dict: str) -> None:
|
||||
"""Check that merged context contains expected values."""
|
||||
expected = json.loads(expected_dict)
|
||||
for key, value in expected.items():
|
||||
assert key in context.merged_context
|
||||
assert context.merged_context[key] == value
|
||||
|
||||
|
||||
@then("the merged context should include all resolver outputs")
|
||||
def step_check_all_resolver_outputs(context: Any) -> None:
|
||||
"""Check that all resolver outputs are in merged context."""
|
||||
for resolver in context.resolvers:
|
||||
for key in resolver._output:
|
||||
assert key in context.merged_context
|
||||
|
||||
|
||||
@then("the merged context should have {count:d} keys")
|
||||
def step_check_merged_context_key_count(context: Any, count: int) -> None:
|
||||
"""Check the number of keys in merged context."""
|
||||
assert len(context.merged_context) == count
|
||||
|
||||
|
||||
@then("the merged context should have {expected_dict}")
|
||||
def step_check_merged_context_has(context: Any, expected_dict: str) -> None:
|
||||
"""Check that merged context has expected key-value pairs."""
|
||||
expected = json.loads(expected_dict)
|
||||
for key, value in expected.items():
|
||||
assert key in context.merged_context
|
||||
assert context.merged_context[key] == value
|
||||
|
||||
|
||||
@then("the assembly should not fail")
|
||||
def step_check_assembly_not_failed(context: Any) -> None:
|
||||
"""Check that assembly did not fail."""
|
||||
assert context.merged_context is not None
|
||||
|
||||
|
||||
@then("the error should be logged")
|
||||
def step_check_error_logged(context: Any) -> None:
|
||||
"""Check that error was logged."""
|
||||
# Logging is verified by the absence of an exception during assembly.
|
||||
pass
|
||||
|
||||
|
||||
@then("the base context should be returned unchanged")
|
||||
def step_check_base_context_unchanged(context: Any) -> None:
|
||||
"""Check that base context is returned unchanged."""
|
||||
for key, value in context.base_context.items():
|
||||
assert context.merged_context[key] == value
|
||||
|
||||
|
||||
@then("resolver2 should have received scope1 in its context")
|
||||
def step_check_resolver_received_scope(context: Any) -> None:
|
||||
"""Check that resolver2 received scope1."""
|
||||
resolver2 = context.resolvers[1]
|
||||
last_context = resolver2.get_last_context()
|
||||
assert last_context is not None
|
||||
assert "scope1" in last_context
|
||||
|
||||
|
||||
@then("the merged context should include scope1 and scope2")
|
||||
def step_check_both_scopes(context: Any) -> None:
|
||||
"""Check that both scope1 and scope2 are in merged context."""
|
||||
assert "scope1" in context.merged_context
|
||||
assert "scope2" in context.merged_context
|
||||
|
||||
|
||||
@then("registration should fail with a validation error")
|
||||
def step_check_validation_error(context: Any) -> None:
|
||||
"""Check that registration failed with validation error."""
|
||||
assert context.registration_error is not None
|
||||
|
||||
|
||||
@then("registration should fail with a duplicate error")
|
||||
def step_check_duplicate_error(context: Any) -> None:
|
||||
"""Check that registration failed with duplicate error."""
|
||||
assert context.registration_error is not None
|
||||
assert isinstance(context.registration_error, ValueError)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Registry for custom scope chain resolvers.
|
||||
|
||||
Manages the registration and invocation of custom scope resolvers that extend
|
||||
the built-in project/actor/plan scope chain during context assembly.
|
||||
|
||||
Based on issue #7545 and Epic #5507 (Pluggable Scope Chain Resolution).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ScopeChainResolver(Protocol):
|
||||
"""Protocol for custom scope chain resolver plugins.
|
||||
|
||||
A scope chain resolver is a callable that receives the current scope context
|
||||
and returns additional scope data to merge into the assembled context.
|
||||
|
||||
Resolvers are invoked in registration order after the built-in scope chain
|
||||
(project/actor/plan) has been resolved.
|
||||
"""
|
||||
|
||||
@property
|
||||
def resolver_name(self) -> str:
|
||||
"""Return the name of this resolver."""
|
||||
...
|
||||
|
||||
def resolve(self, scope_context: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
"""Resolve custom scope data.
|
||||
|
||||
Args:
|
||||
scope_context: The current scope context containing built-in scopes
|
||||
(project, actor, plan) and any previously resolved custom scopes.
|
||||
|
||||
Returns:
|
||||
A mapping of additional scope data to merge into the context.
|
||||
Empty mapping if no additional scope data is available.
|
||||
|
||||
Raises:
|
||||
Exception: Any exception raised by the resolver. Exceptions are
|
||||
caught and logged by the registry without aborting assembly.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class ScopeChainRegistry:
|
||||
"""Registry for managing custom scope chain resolvers.
|
||||
|
||||
Maintains a thread-safe registry of custom scope resolvers and provides
|
||||
methods to register, invoke, and manage them during context assembly.
|
||||
|
||||
The registry invokes resolvers in registration order, merging their output
|
||||
into the assembled context. Resolver errors are caught and logged without
|
||||
aborting assembly (graceful degradation).
|
||||
|
||||
Example:
|
||||
|
||||
registry = ScopeChainRegistry()
|
||||
registry.register_resolver(my_custom_resolver)
|
||||
merged_scope = registry.resolve_all(base_scope_context)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the scope chain registry."""
|
||||
self._lock = threading.RLock()
|
||||
self._resolvers: dict[str, ScopeChainResolver] = {}
|
||||
self._logger = logger.bind(component="scope_chain_registry")
|
||||
|
||||
def register_resolver(self, resolver: ScopeChainResolver) -> None:
|
||||
"""Register a custom scope chain resolver.
|
||||
|
||||
Args:
|
||||
resolver: The resolver instance implementing ScopeChainResolver.
|
||||
|
||||
Raises:
|
||||
ValueError: If a resolver with the same name is already registered,
|
||||
or if the resolver name is empty or invalid.
|
||||
TypeError: If the resolver does not implement ScopeChainResolver.
|
||||
"""
|
||||
if not isinstance(resolver, ScopeChainResolver):
|
||||
msg = (
|
||||
f"Resolver must implement ScopeChainResolver protocol, "
|
||||
f"got {type(resolver).__name__}"
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
name = resolver.resolver_name
|
||||
if not name or not name.strip():
|
||||
msg = "Resolver name must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
|
||||
with self._lock:
|
||||
if name in self._resolvers:
|
||||
msg = f"Resolver '{name}' is already registered"
|
||||
raise ValueError(msg)
|
||||
|
||||
self._resolvers[name] = resolver
|
||||
self._logger.info(
|
||||
"scope_chain_resolver_registered",
|
||||
resolver_name=name,
|
||||
)
|
||||
|
||||
def unregister_resolver(self, resolver_name: str) -> None:
|
||||
"""Unregister a custom scope chain resolver.
|
||||
|
||||
Args:
|
||||
resolver_name: The name of the resolver to unregister.
|
||||
|
||||
Raises:
|
||||
KeyError: If the resolver is not registered.
|
||||
"""
|
||||
with self._lock:
|
||||
if resolver_name not in self._resolvers:
|
||||
msg = f"Resolver '{resolver_name}' is not registered"
|
||||
raise KeyError(msg)
|
||||
|
||||
del self._resolvers[resolver_name]
|
||||
self._logger.info(
|
||||
"scope_chain_resolver_unregistered",
|
||||
resolver_name=resolver_name,
|
||||
)
|
||||
|
||||
def list_resolvers(self) -> list[str]:
|
||||
"""Return the names of all registered resolvers.
|
||||
|
||||
Returns:
|
||||
List of resolver names in registration order.
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._resolvers.keys())
|
||||
|
||||
def resolve_all(self, scope_context: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
"""Invoke all registered resolvers and merge their output.
|
||||
|
||||
Resolvers are invoked in registration order. Each resolver receives
|
||||
the scope context with all previously resolved custom scopes merged in.
|
||||
|
||||
Resolver errors are caught and logged without aborting assembly.
|
||||
|
||||
Args:
|
||||
scope_context: The base scope context (typically containing
|
||||
project, actor, plan scopes).
|
||||
|
||||
Returns:
|
||||
A merged mapping containing the base scope context plus all
|
||||
custom scope data from resolvers.
|
||||
"""
|
||||
merged: dict[str, Any] = dict(scope_context)
|
||||
|
||||
with self._lock:
|
||||
resolvers_to_invoke = list(self._resolvers.items())
|
||||
|
||||
for resolver_name, resolver in resolvers_to_invoke:
|
||||
try:
|
||||
custom_scope = resolver.resolve(merged)
|
||||
if custom_scope:
|
||||
merged.update(custom_scope)
|
||||
self._logger.debug(
|
||||
"scope_chain_resolver_invoked",
|
||||
resolver_name=resolver_name,
|
||||
scope_keys=list(custom_scope.keys()),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"scope_chain_resolver_error",
|
||||
resolver_name=resolver_name,
|
||||
error=str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all registered resolvers."""
|
||||
with self._lock:
|
||||
self._resolvers.clear()
|
||||
self._logger.debug("scope_chain_registry_cleared")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ScopeChainRegistry",
|
||||
"ScopeChainResolver",
|
||||
]
|
||||
Reference in New Issue
Block a user