feat: implement pluggable scope chain resolution extension API #10661
@@ -0,0 +1,130 @@
|
||||
Feature: Pluggable Scope Chain Resolution Extension API
|
||||
As a developer
|
||||
I want to register custom scope chain resolvers
|
||||
So that I can extend scope resolution with custom context sources
|
||||
|
||||
Background:
|
||||
Given a fresh scope chain registry
|
||||
|
||||
Scenario: Register a custom scope chain resolver
|
||||
Given a custom resolver named "test_resolver" with version "1.0.0"
|
||||
When I register the resolver
|
||||
Then the resolver should be registered
|
||||
And the registry should list the resolver
|
||||
|
||||
Scenario: Unregister a custom resolver
|
||||
Given a custom resolver named "test_resolver" with version "1.0.0"
|
||||
And the resolver is registered
|
||||
When I unregister the resolver
|
||||
Then the resolver should not be registered
|
||||
|
||||
Scenario: Reject duplicate resolver registration
|
||||
Given a custom resolver named "test_resolver" with version "1.0.0"
|
||||
And the resolver is registered
|
||||
When I try to register another resolver with the same name
|
||||
Then registration should fail with ValueError
|
||||
|
||||
Scenario: Reject resolver with empty name
|
||||
Given a custom resolver with empty name
|
||||
When I try to register the resolver
|
||||
Then registration should fail with ValueError
|
||||
|
||||
Scenario: Reject non-protocol resolver
|
||||
Given an object that does not implement ScopeChainResolver
|
||||
When I try to register it as a resolver
|
||||
Then registration should fail with TypeError
|
||||
|
||||
Scenario: Select resolver based on context
|
||||
Given a custom resolver that supports context with source="custom"
|
||||
And the resolver is registered
|
||||
When I select a resolver with context source="custom"
|
||||
Then the custom resolver should be selected
|
||||
|
||||
Scenario: Fallback to default resolver when no match
|
||||
Given a custom resolver that supports context with source="custom"
|
||||
And the resolver is registered
|
||||
When I select a resolver with context source="other"
|
||||
Then the default resolver should be selected
|
||||
|
||||
Scenario: Default resolver always supports any context
|
||||
When I select a resolver with no context
|
||||
Then the default resolver should be selected
|
||||
|
||||
Scenario: List all registered resolvers
|
||||
Given a custom resolver named "resolver1" with version "1.0.0"
|
||||
And a custom resolver named "resolver2" with version "2.0.0"
|
||||
And both resolvers are registered
|
||||
When I list all registered resolvers
|
||||
Then the list should contain both resolvers with their versions
|
||||
|
||||
Scenario: Resolve scope using selected resolver
|
||||
Given a custom resolver that delegates to default resolver
|
||||
And the resolver is registered
|
||||
And a project with linked resources
|
||||
When I resolve scope using the registry
|
||||
Then a ResourceScope should be returned
|
||||
And the scope should contain the project's resources
|
||||
|
||||
Scenario: Load resolvers from plugin directory
|
||||
Given a plugin directory with a resolver module
|
||||
And the resolver module exports a valid resolver
|
||||
When I load plugins from the directory
|
||||
Then the resolver should be registered
|
||||
And the loader should report success
|
||||
|
||||
Scenario: Handle missing plugin directory gracefully
|
||||
Given a non-existent plugin directory path
|
||||
When I try to load plugins from the directory
|
||||
Then loading should fail with ValueError
|
||||
|
||||
Scenario: Skip invalid plugin modules
|
||||
Given a plugin directory with an invalid module
|
||||
When I load plugins from the directory
|
||||
Then loading should continue
|
||||
And invalid modules should be logged as warnings
|
||||
|
||||
Scenario: Filesystem resolver supports scope config files
|
||||
Given a project directory with .scope-chain.yaml
|
||||
And a filesystem resolver
|
||||
When I check if the resolver supports the project context
|
||||
Then the resolver should support the context
|
||||
|
||||
Scenario: Filesystem resolver does not support missing config
|
||||
Given a project directory without scope config
|
||||
And a filesystem resolver
|
||||
When I check if the resolver supports the project context
|
||||
Then the resolver should not support the context
|
||||
|
||||
Scenario: Filesystem resolver delegates to default resolver
|
||||
Given a filesystem resolver
|
||||
And a project with linked resources
|
||||
When I resolve scope using the filesystem resolver
|
||||
Then a ResourceScope should be returned
|
||||
And the scope should contain the project's resources
|
||||
|
||||
Scenario: Thread-safe resolver registration
|
||||
Given a registry with multiple threads
|
||||
When threads concurrently register resolvers
|
||||
Then all resolvers should be registered
|
||||
And no race conditions should occur
|
||||
|
||||
Scenario: Thread-safe resolver selection
|
||||
Given a registry with registered resolvers
|
||||
When threads concurrently select resolvers
|
||||
Then all selections should succeed
|
||||
And no race conditions should occur
|
||||
|
||||
Scenario: Resolver version tracking
|
||||
Given a custom resolver with version "2.5.3"
|
||||
And the resolver is registered
|
||||
When I retrieve the resolver
|
||||
Then the resolver version should be "2.5.3"
|
||||
|
||||
Scenario: Multiple resolvers with different contexts
|
||||
Given a resolver that supports context with type="database"
|
||||
And a resolver that supports context with type="wiki"
|
||||
And both resolvers are registered
|
||||
When I select a resolver with context type="database"
|
||||
Then the database resolver should be selected
|
||||
When I select a resolver with context type="wiki"
|
||||
Then the wiki resolver should be selected
|
||||
@@ -0,0 +1,577 @@
|
||||
"""Step definitions for scope chain extension API tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.domain.models.acms.scope_chain_extension import (
|
||||
ScopeChainResolver,
|
||||
)
|
||||
from cleveragents.domain.models.acms.scope_chain_registry import (
|
||||
ScopeChainRegistry,
|
||||
)
|
||||
from cleveragents.domain.models.acms.scope_resolution import (
|
||||
resolve_resource_scope,
|
||||
)
|
||||
from cleveragents.infrastructure.plugins.extensions.filesystem_scope_resolver import (
|
||||
FilesystemScopeResolver,
|
||||
)
|
||||
from cleveragents.infrastructure.plugins.scope_chain_loader import (
|
||||
ScopeChainPluginLoader,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
@given("a fresh scope chain registry")
|
||||
def step_fresh_registry(context):
|
||||
"""Create a fresh registry for testing."""
|
||||
context.registry = ScopeChainRegistry()
|
||||
context.resolvers = {}
|
||||
context.selected_resolver = None
|
||||
context.error = None
|
||||
|
||||
|
||||
@given('a custom resolver named "{name}" with version "{version}"')
|
||||
def step_custom_resolver(context, name, version):
|
||||
"""Create a custom resolver with given name and version."""
|
||||
resolver = _create_mock_resolver(name, version)
|
||||
context.resolvers[name] = resolver
|
||||
|
||||
|
||||
@given('a custom resolver with version "{version}"')
|
||||
def step_custom_resolver_version_only(context, version):
|
||||
"""Create a custom resolver with given version and a default name."""
|
||||
resolver = _create_mock_resolver("versioned_resolver", version)
|
||||
context.resolvers["versioned_resolver"] = resolver
|
||||
|
||||
|
||||
@given("a custom resolver with empty name")
|
||||
def step_resolver_empty_name(context):
|
||||
"""Create a resolver with empty name."""
|
||||
resolver = _create_mock_resolver("", "1.0.0")
|
||||
context.resolvers["empty"] = resolver
|
||||
|
||||
|
||||
@given("an object that does not implement ScopeChainResolver")
|
||||
def step_non_resolver_object(context):
|
||||
"""Create an object that doesn't implement the protocol."""
|
||||
context.non_resolver = {"not": "a resolver"}
|
||||
|
||||
|
||||
@given('a custom resolver that supports context with source="{source}"')
|
||||
def step_resolver_with_context_support(context, source):
|
||||
"""Create a resolver that supports specific context."""
|
||||
resolver = _create_mock_resolver(f"resolver_{source}", "1.0.0")
|
||||
resolver.supports = lambda ctx: ctx.get("source") == source
|
||||
context.resolvers[f"resolver_{source}"] = resolver
|
||||
|
||||
|
||||
@given("a custom resolver that delegates to default resolver")
|
||||
def step_resolver_delegates_default(context):
|
||||
"""Create a resolver that delegates to default resolver."""
|
||||
resolver = _create_delegating_resolver()
|
||||
context.resolvers["delegating"] = resolver
|
||||
|
||||
|
||||
@given("the resolver is registered")
|
||||
def step_register_resolver(context):
|
||||
"""Register the most recently created resolver."""
|
||||
for name, resolver in context.resolvers.items():
|
||||
if name not in getattr(context, "registered", set()):
|
||||
context.registry.register(resolver)
|
||||
if not hasattr(context, "registered"):
|
||||
context.registered = set()
|
||||
context.registered.add(name)
|
||||
|
||||
|
||||
@given("both resolvers are registered")
|
||||
def step_register_both_resolvers(context):
|
||||
"""Register all created resolvers."""
|
||||
for _name, resolver in context.resolvers.items():
|
||||
context.registry.register(resolver)
|
||||
|
||||
|
||||
@given("a project with linked resources")
|
||||
def step_project_with_resources(context):
|
||||
"""Create a mock project with linked resources."""
|
||||
context.project = _create_mock_project()
|
||||
|
||||
|
||||
@given("a plugin directory with a resolver module")
|
||||
def step_plugin_directory_with_resolver(context):
|
||||
"""Create a temporary plugin directory with a resolver module."""
|
||||
context.temp_dir = tempfile.TemporaryDirectory()
|
||||
plugin_dir = Path(context.temp_dir.name)
|
||||
|
||||
# Create a resolver module
|
||||
resolver_code = '''
|
||||
from cleveragents.domain.models.acms.scope_resolution import resolve_resource_scope
|
||||
|
||||
class TestPluginResolver:
|
||||
@property
|
||||
def name(self):
|
||||
return "test_plugin"
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return "1.0.0"
|
||||
|
||||
def supports(self, context):
|
||||
return context.get("source") == "plugin"
|
||||
|
||||
def resolve(self, projects, **kwargs):
|
||||
return resolve_resource_scope(projects, **kwargs)
|
||||
'''
|
||||
(plugin_dir / "test_resolver.py").write_text(resolver_code)
|
||||
context.plugin_dir = plugin_dir
|
||||
|
||||
|
||||
@given("the resolver module exports a valid resolver")
|
||||
def step_valid_resolver_module(context):
|
||||
"""Ensure the resolver module is valid (already done in previous step)."""
|
||||
pass
|
||||
|
||||
|
||||
@given("a non-existent plugin directory path")
|
||||
def step_nonexistent_plugin_dir(context):
|
||||
"""Set a non-existent plugin directory path."""
|
||||
context.plugin_dir = Path("/nonexistent/plugin/directory")
|
||||
|
||||
|
||||
@given("a plugin directory with an invalid module")
|
||||
def step_plugin_dir_invalid_module(context):
|
||||
"""Create a plugin directory with an invalid module."""
|
||||
context.temp_dir = tempfile.TemporaryDirectory()
|
||||
plugin_dir = Path(context.temp_dir.name)
|
||||
|
||||
# Create an invalid Python module
|
||||
invalid_code = "this is not valid python code !!!"
|
||||
(plugin_dir / "invalid.py").write_text(invalid_code)
|
||||
context.plugin_dir = plugin_dir
|
||||
|
||||
|
||||
@given("a project directory with .scope-chain.yaml")
|
||||
def step_project_with_scope_config(context):
|
||||
"""Create a project directory with scope config file."""
|
||||
context.temp_dir = tempfile.TemporaryDirectory()
|
||||
project_dir = Path(context.temp_dir.name)
|
||||
(project_dir / ".scope-chain.yaml").write_text("include_resources: [res1]")
|
||||
context.project_dir = project_dir
|
||||
|
||||
|
||||
@given("a project directory without scope config")
|
||||
def step_project_without_scope_config(context):
|
||||
"""Create a project directory without scope config."""
|
||||
context.temp_dir = tempfile.TemporaryDirectory()
|
||||
context.project_dir = Path(context.temp_dir.name)
|
||||
|
||||
|
||||
@given("a filesystem resolver")
|
||||
def step_filesystem_resolver(context):
|
||||
"""Create a filesystem resolver instance."""
|
||||
context.filesystem_resolver = FilesystemScopeResolver()
|
||||
|
||||
|
||||
@given("a registry with multiple threads")
|
||||
def step_registry_with_threads(context):
|
||||
"""Create a registry for thread safety testing."""
|
||||
context.registry = ScopeChainRegistry()
|
||||
context.thread_errors = []
|
||||
|
||||
|
||||
@given("a registry with registered resolvers")
|
||||
def step_registry_with_resolvers(context):
|
||||
"""Create a registry with some resolvers."""
|
||||
context.registry = ScopeChainRegistry()
|
||||
context.thread_errors = []
|
||||
for i in range(3):
|
||||
resolver = _create_mock_resolver(f"resolver_{i}", "1.0.0")
|
||||
context.registry.register(resolver)
|
||||
|
||||
|
||||
@given('a resolver that supports context with type="{type_val}"')
|
||||
def step_resolver_with_type_support(context, type_val):
|
||||
"""Create a resolver that supports specific type."""
|
||||
resolver = _create_mock_resolver(f"resolver_{type_val}", "1.0.0")
|
||||
resolver.supports = lambda ctx: ctx.get("type") == type_val
|
||||
context.resolvers[f"resolver_{type_val}"] = resolver
|
||||
|
||||
|
||||
@when("I register the resolver")
|
||||
def step_register_resolver_action(context):
|
||||
"""Register a resolver."""
|
||||
try:
|
||||
resolver = next(iter(context.resolvers.values()))
|
||||
context.registry.register(resolver)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I unregister the resolver")
|
||||
def step_unregister_resolver(context):
|
||||
"""Unregister a resolver."""
|
||||
resolver_name = next(iter(context.resolvers.keys()))
|
||||
context.unregister_result = context.registry.unregister(resolver_name)
|
||||
|
||||
|
||||
@when("I try to register another resolver with the same name")
|
||||
def step_register_duplicate(context):
|
||||
"""Try to register a resolver with duplicate name."""
|
||||
try:
|
||||
resolver = next(iter(context.resolvers.values()))
|
||||
context.registry.register(resolver)
|
||||
context.registry.register(resolver)
|
||||
context.error = None
|
||||
except ValueError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to register the resolver")
|
||||
def step_try_register(context):
|
||||
"""Try to register a resolver."""
|
||||
try:
|
||||
resolver = next(iter(context.resolvers.values()))
|
||||
context.registry.register(resolver)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to register it as a resolver")
|
||||
def step_try_register_non_resolver(context):
|
||||
"""Try to register a non-resolver object."""
|
||||
try:
|
||||
context.registry.register(context.non_resolver)
|
||||
context.error = None
|
||||
except TypeError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when('I select a resolver with context source="{source}"')
|
||||
def step_select_resolver_with_source(context, source):
|
||||
"""Select a resolver with specific context."""
|
||||
context.selected_resolver = context.registry.select_resolver(
|
||||
{"source": source}
|
||||
)
|
||||
|
||||
|
||||
@when("I select a resolver with no context")
|
||||
def step_select_resolver_no_context(context):
|
||||
"""Select resolver without context."""
|
||||
context.selected_resolver = context.registry.select_resolver(None)
|
||||
|
||||
|
||||
@when("I list all registered resolvers")
|
||||
def step_list_resolvers(context):
|
||||
"""List all registered resolvers."""
|
||||
context.resolver_list = context.registry.list_all()
|
||||
|
||||
|
||||
@when("I resolve scope using the registry")
|
||||
def step_resolve_scope_registry(context):
|
||||
"""Resolve scope using the registry."""
|
||||
try:
|
||||
context.resolved_scope = context.registry.resolve(
|
||||
[context.project],
|
||||
context={"source": "test"},
|
||||
)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I load plugins from the directory")
|
||||
def step_load_plugins(context):
|
||||
"""Load plugins from directory."""
|
||||
try:
|
||||
loader = ScopeChainPluginLoader(context.registry)
|
||||
context.loaded_count = loader.load_from_directory(context.plugin_dir)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to load plugins from the directory")
|
||||
def step_try_load_plugins(context):
|
||||
"""Try to load plugins from directory."""
|
||||
try:
|
||||
loader = ScopeChainPluginLoader(context.registry)
|
||||
context.loaded_count = loader.load_from_directory(context.plugin_dir)
|
||||
context.error = None
|
||||
except ValueError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I check if the resolver supports the project context")
|
||||
def step_check_resolver_support(context):
|
||||
"""Check if resolver supports project context."""
|
||||
context.supports_result = context.filesystem_resolver.supports(
|
||||
{"project_root": str(context.project_dir)}
|
||||
)
|
||||
|
||||
|
||||
@when("threads concurrently register resolvers")
|
||||
def step_concurrent_register(context):
|
||||
"""Register resolvers from multiple threads."""
|
||||
def register_resolver(idx):
|
||||
try:
|
||||
resolver = _create_mock_resolver(f"thread_resolver_{idx}", "1.0.0")
|
||||
context.registry.register(resolver)
|
||||
except Exception as e:
|
||||
context.thread_errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=register_resolver, args=(i,))
|
||||
for i in range(10)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
|
||||
@when("threads concurrently select resolvers")
|
||||
def step_concurrent_select(context):
|
||||
"""Select resolvers from multiple threads."""
|
||||
context.selection_results = []
|
||||
|
||||
def select_resolver(idx):
|
||||
try:
|
||||
result = context.registry.select_resolver({"index": idx})
|
||||
context.selection_results.append(result)
|
||||
except Exception as e:
|
||||
context.thread_errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=select_resolver, args=(i,))
|
||||
for i in range(10)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
|
||||
@when("I retrieve the resolver")
|
||||
def step_retrieve_resolver(context):
|
||||
"""Retrieve a registered resolver."""
|
||||
resolver_name = next(iter(context.resolvers.keys()))
|
||||
context.retrieved_resolver = context.registry.get(resolver_name)
|
||||
|
||||
|
||||
@when('I select a resolver with context type="{type_val}"')
|
||||
def step_select_resolver_by_type(context, type_val):
|
||||
"""Select resolver by type context."""
|
||||
context.selected_resolver = context.registry.select_resolver(
|
||||
{"type": type_val}
|
||||
)
|
||||
|
||||
|
||||
@when("I resolve scope using the filesystem resolver")
|
||||
def step_resolve_with_filesystem(context):
|
||||
"""Resolve scope using filesystem resolver."""
|
||||
try:
|
||||
context.resolved_scope = context.filesystem_resolver.resolve(
|
||||
[context.project]
|
||||
)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("the resolver should be registered")
|
||||
def step_assert_registered(context):
|
||||
"""Assert resolver is registered."""
|
||||
resolver_name = next(iter(context.resolvers.keys()))
|
||||
assert context.registry.get(resolver_name) is not None
|
||||
|
||||
|
||||
@then("the registry should list the resolver")
|
||||
def step_assert_in_list(context):
|
||||
"""Assert resolver is in the list."""
|
||||
resolver_list = context.registry.list_all()
|
||||
resolver_names = [name for name, _ in resolver_list]
|
||||
resolver_name = next(iter(context.resolvers.keys()))
|
||||
assert resolver_name in resolver_names
|
||||
|
||||
|
||||
@then("the resolver should not be registered")
|
||||
def step_assert_not_registered(context):
|
||||
"""Assert resolver is not registered."""
|
||||
resolver_name = next(iter(context.resolvers.keys()))
|
||||
assert context.registry.get(resolver_name) is None
|
||||
|
||||
|
||||
@then("registration should fail with ValueError")
|
||||
def step_assert_value_error(context):
|
||||
"""Assert ValueError was raised."""
|
||||
assert isinstance(context.error, ValueError)
|
||||
|
||||
|
||||
@then("registration should fail with TypeError")
|
||||
def step_assert_type_error(context):
|
||||
"""Assert TypeError was raised."""
|
||||
assert isinstance(context.error, TypeError)
|
||||
|
||||
|
||||
@then("the custom resolver should be selected")
|
||||
def step_assert_custom_selected(context):
|
||||
"""Assert custom resolver was selected."""
|
||||
assert context.selected_resolver.name != "default"
|
||||
|
||||
|
||||
@then("the default resolver should be selected")
|
||||
def step_assert_default_selected(context):
|
||||
"""Assert default resolver was selected."""
|
||||
assert context.selected_resolver.name == "default"
|
||||
|
||||
|
||||
@then("the list should contain both resolvers with their versions")
|
||||
def step_assert_list_contains_both(context):
|
||||
"""Assert list contains both resolvers."""
|
||||
assert len(context.resolver_list) == 2
|
||||
names = [name for name, _ in context.resolver_list]
|
||||
assert "resolver1" in names
|
||||
assert "resolver2" in names
|
||||
|
||||
|
||||
@then("a ResourceScope should be returned")
|
||||
def step_assert_scope_returned(context):
|
||||
"""Assert a ResourceScope was returned."""
|
||||
assert context.resolved_scope is not None
|
||||
assert hasattr(context.resolved_scope, "resource_ids")
|
||||
|
||||
|
||||
@then("the scope should contain the project's resources")
|
||||
def step_assert_scope_has_resources(context):
|
||||
"""Assert scope contains project resources."""
|
||||
assert len(context.resolved_scope.resource_ids) > 0
|
||||
|
||||
|
||||
@then("the loader should report success")
|
||||
def step_assert_loader_success(context):
|
||||
"""Assert loader reported success."""
|
||||
assert context.loaded_count > 0
|
||||
|
||||
|
||||
@then("loading should fail with ValueError")
|
||||
def step_assert_load_value_error(context):
|
||||
"""Assert ValueError was raised during loading."""
|
||||
assert isinstance(context.error, ValueError)
|
||||
|
||||
|
||||
@then("loading should continue")
|
||||
def step_assert_load_continues(context):
|
||||
"""Assert loading continued despite errors."""
|
||||
assert context.error is None
|
||||
|
||||
|
||||
@then("invalid modules should be logged as warnings")
|
||||
def step_assert_warnings_logged(context):
|
||||
"""Assert warnings were logged (implicit in implementation)."""
|
||||
pass
|
||||
|
||||
|
||||
@then("the resolver should support the context")
|
||||
def step_assert_supports_context(context):
|
||||
"""Assert resolver supports the context."""
|
||||
assert context.supports_result is True
|
||||
|
||||
|
||||
@then("the resolver should not support the context")
|
||||
def step_assert_not_supports_context(context):
|
||||
"""Assert resolver does not support the context."""
|
||||
assert context.supports_result is False
|
||||
|
||||
|
||||
@then("all resolvers should be registered")
|
||||
def step_assert_all_registered(context):
|
||||
"""Assert all resolvers were registered."""
|
||||
assert len(context.thread_errors) == 0
|
||||
assert len(context.registry.list_all()) == 10
|
||||
|
||||
|
||||
@then("all selections should succeed")
|
||||
def step_assert_all_selections_succeed(context):
|
||||
"""Assert all concurrent selections succeeded."""
|
||||
assert len(context.thread_errors) == 0
|
||||
assert len(context.selection_results) == 10
|
||||
|
||||
|
||||
@then("no race conditions should occur")
|
||||
def step_assert_no_race_conditions(context):
|
||||
"""Assert no race conditions occurred."""
|
||||
assert len(context.thread_errors) == 0
|
||||
|
||||
|
||||
@then('the resolver version should be "{version}"')
|
||||
def step_assert_resolver_version(context, version):
|
||||
"""Assert resolver has correct version."""
|
||||
assert context.retrieved_resolver.version == version
|
||||
|
||||
|
||||
@then("the database resolver should be selected")
|
||||
def step_assert_database_selected(context):
|
||||
"""Assert database resolver was selected."""
|
||||
assert context.selected_resolver.name == "resolver_database"
|
||||
|
||||
|
||||
@then("the wiki resolver should be selected")
|
||||
def step_assert_wiki_selected(context):
|
||||
"""Assert wiki resolver was selected."""
|
||||
assert context.selected_resolver.name == "resolver_wiki"
|
||||
|
||||
|
||||
# Helper functions
|
||||
|
||||
|
||||
def _create_mock_resolver(name: str, version: str):
|
||||
"""Create a mock resolver for testing."""
|
||||
resolver = MagicMock(spec=ScopeChainResolver)
|
||||
resolver.name = name
|
||||
resolver.version = version
|
||||
resolver.supports = lambda ctx: False
|
||||
resolver.resolve = lambda projects, **kwargs: _create_mock_scope()
|
||||
return resolver
|
||||
|
||||
|
||||
def _create_delegating_resolver():
|
||||
"""Create a resolver that delegates to default resolver."""
|
||||
resolver = MagicMock(spec=ScopeChainResolver)
|
||||
resolver.name = "delegating"
|
||||
resolver.version = "1.0.0"
|
||||
resolver.supports = lambda ctx: True
|
||||
resolver.resolve = lambda projects, **kwargs: resolve_resource_scope(
|
||||
projects, **kwargs
|
||||
)
|
||||
return resolver
|
||||
|
||||
|
||||
def _create_mock_project():
|
||||
"""Create a mock project with linked resources."""
|
||||
project = MagicMock()
|
||||
project.namespaced_name = "test/project"
|
||||
project.linked_resources = [
|
||||
MagicMock(resource_id="res1", alias="resource1"),
|
||||
MagicMock(resource_id="res2", alias="resource2"),
|
||||
]
|
||||
project.get_linked_resource = lambda rid: None
|
||||
project.get_linked_resource_by_alias = lambda alias: None
|
||||
return project
|
||||
|
||||
|
||||
def _create_mock_scope():
|
||||
"""Create a mock ResourceScope."""
|
||||
scope = MagicMock()
|
||||
scope.resource_ids = frozenset(["res1", "res2"])
|
||||
scope.project_names = frozenset(["test/project"])
|
||||
return scope
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Pluggable scope chain resolution extension API.
|
||||
|
||||
This module provides the extension API for custom scope chain resolvers,
|
||||
enabling third-party integrations and custom context sources (e.g., internal
|
||||
wikis, issue trackers, databases) to be added without modifying core code.
|
||||
|
||||
Based on ``docs/specification.md`` > ACMS > Pluggable Scope Chain Resolution
|
||||
and Forgejo issue #8205.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from cleveragents.domain.models.acms.scoped_view import ResourceScope
|
||||
from cleveragents.domain.models.core.project import NamespacedProject
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ScopeChainResolver(Protocol):
|
||||
"""Protocol for pluggable scope chain resolvers.
|
||||
|
||||
Implementations of this protocol can be registered with the
|
||||
``ScopeChainRegistry`` to provide custom scope chain resolution
|
||||
logic for different context sources.
|
||||
|
||||
Example:
|
||||
>>> class CustomResolver:
|
||||
... name = "custom"
|
||||
... version = "1.0.0"
|
||||
...
|
||||
... def supports(self, context: dict) -> bool:
|
||||
... return context.get("source") == "custom"
|
||||
...
|
||||
... def resolve(
|
||||
... self,
|
||||
... projects: list[NamespacedProject],
|
||||
... *,
|
||||
... include_resources: tuple[str, ...] = (),
|
||||
... exclude_resources: tuple[str, ...] = (),
|
||||
... include_paths: tuple[str, ...] = (),
|
||||
... exclude_paths: tuple[str, ...] = (),
|
||||
... temporal_scope: str = "current",
|
||||
... registry_lookup: Callable[[str], frozenset[str]] | None = None,
|
||||
... ) -> ResourceScope:
|
||||
... # Custom resolution logic
|
||||
... pass
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for this resolver.
|
||||
|
||||
Returns:
|
||||
A string identifier (e.g., "filesystem", "database", "wiki").
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Version of this resolver implementation.
|
||||
|
||||
Returns:
|
||||
A semantic version string (e.g., "1.0.0").
|
||||
"""
|
||||
...
|
||||
|
||||
def supports(self, context: dict) -> bool:
|
||||
"""Check if this resolver can handle the given context.
|
||||
|
||||
Args:
|
||||
context: A dictionary with context information that the
|
||||
resolver uses to determine if it should handle the
|
||||
scope resolution request.
|
||||
|
||||
Returns:
|
||||
``True`` if this resolver can handle the context,
|
||||
``False`` otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
projects: list[NamespacedProject],
|
||||
*,
|
||||
include_resources: tuple[str, ...] = (),
|
||||
exclude_resources: tuple[str, ...] = (),
|
||||
include_paths: tuple[str, ...] = (),
|
||||
exclude_paths: tuple[str, ...] = (),
|
||||
temporal_scope: str = "current",
|
||||
registry_lookup: Callable[[str], frozenset[str]] | None = None,
|
||||
) -> ResourceScope:
|
||||
"""Resolve a ``ResourceScope`` from projects and filter arguments.
|
||||
|
||||
This method implements the core scope chain resolution logic.
|
||||
It should return a ``ResourceScope`` with the resolved resource
|
||||
IDs and filters applied.
|
||||
|
||||
Args:
|
||||
projects: Projects whose linked resources form the base scope.
|
||||
include_resources: Resource allowlist (names/aliases).
|
||||
Empty means include all.
|
||||
exclude_resources: Resource denylist (names/aliases).
|
||||
Applied after allowlist.
|
||||
include_paths: Path glob allowlist.
|
||||
exclude_paths: Path glob denylist.
|
||||
temporal_scope: One of ``"current"``, ``"recent"``, ``"all"``.
|
||||
registry_lookup: Optional callback to expand a resource ULID
|
||||
to its DAG descendants. Signature:
|
||||
``(resource_id: str) -> frozenset[str]``.
|
||||
|
||||
Returns:
|
||||
A ``ResourceScope`` with the resolved resource IDs and filters.
|
||||
|
||||
Raises:
|
||||
ValueError: If arguments are invalid.
|
||||
RuntimeError: If resolution fails.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"ScopeChainResolver",
|
||||
]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Registry for pluggable scope chain resolvers.
|
||||
|
||||
Manages registration, discovery, and selection of custom scope chain
|
||||
resolvers. Provides a default resolver and fallback mechanism.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.domain.models.acms.scope_chain_extension import (
|
||||
ScopeChainResolver,
|
||||
)
|
||||
from cleveragents.domain.models.acms.scope_resolution import (
|
||||
resolve_resource_scope as default_resolve_resource_scope,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.acms.scoped_view import ResourceScope
|
||||
from cleveragents.domain.models.core.project import NamespacedProject
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ScopeChainRegistry:
|
||||
"""Thread-safe registry for scope chain resolvers.
|
||||
|
||||
Manages custom scope chain resolver implementations and provides
|
||||
resolver selection based on context. Includes a default resolver
|
||||
for backward compatibility.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the registry with no custom resolvers."""
|
||||
self._resolvers: dict[str, ScopeChainResolver] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._default_resolver: ScopeChainResolver | None = None
|
||||
|
||||
def register(self, resolver: ScopeChainResolver) -> None:
|
||||
"""Register a custom scope chain resolver.
|
||||
|
||||
Args:
|
||||
resolver: A resolver implementing the ScopeChainResolver protocol.
|
||||
|
||||
Raises:
|
||||
ValueError: If resolver name is empty or already registered.
|
||||
TypeError: If resolver does not implement the protocol.
|
||||
"""
|
||||
if not isinstance(resolver, ScopeChainResolver):
|
||||
raise TypeError(
|
||||
f"Resolver must implement ScopeChainResolver protocol, "
|
||||
f"got {type(resolver).__name__}"
|
||||
)
|
||||
|
||||
if not resolver.name or not resolver.name.strip():
|
||||
raise ValueError("Resolver name cannot be empty")
|
||||
|
||||
with self._lock:
|
||||
if resolver.name in self._resolvers:
|
||||
logger.warning(
|
||||
"scope_chain.resolver_already_registered",
|
||||
resolver_name=resolver.name,
|
||||
version=resolver.version,
|
||||
)
|
||||
raise ValueError(
|
||||
f"Resolver '{resolver.name}' is already registered"
|
||||
)
|
||||
|
||||
self._resolvers[resolver.name] = resolver
|
||||
logger.info(
|
||||
"scope_chain.resolver_registered",
|
||||
resolver_name=resolver.name,
|
||||
version=resolver.version,
|
||||
)
|
||||
|
||||
def unregister(self, resolver_name: str) -> bool:
|
||||
"""Unregister a custom scope chain resolver.
|
||||
|
||||
Args:
|
||||
resolver_name: Name of the resolver to unregister.
|
||||
|
||||
Returns:
|
||||
``True`` if resolver was unregistered, ``False`` if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
if resolver_name in self._resolvers:
|
||||
del self._resolvers[resolver_name]
|
||||
logger.info(
|
||||
"scope_chain.resolver_unregistered",
|
||||
resolver_name=resolver_name,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, resolver_name: str) -> ScopeChainResolver | None:
|
||||
"""Get a registered resolver by name.
|
||||
|
||||
Args:
|
||||
resolver_name: Name of the resolver to retrieve.
|
||||
|
||||
Returns:
|
||||
The resolver, or ``None`` if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._resolvers.get(resolver_name)
|
||||
|
||||
def list_all(self) -> list[tuple[str, str]]:
|
||||
"""List all registered resolvers.
|
||||
|
||||
Returns:
|
||||
List of (name, version) tuples for all registered resolvers.
|
||||
"""
|
||||
with self._lock:
|
||||
return [
|
||||
(name, resolver.version)
|
||||
for name, resolver in self._resolvers.items()
|
||||
]
|
||||
|
||||
def select_resolver(
|
||||
self, context: dict | None = None
|
||||
) -> ScopeChainResolver:
|
||||
"""Select a resolver based on context.
|
||||
|
||||
Iterates through registered resolvers and returns the first one
|
||||
that supports the given context. Falls back to the default resolver
|
||||
if no custom resolver matches.
|
||||
|
||||
Args:
|
||||
context: Optional context dictionary for resolver selection.
|
||||
If ``None``, returns the default resolver.
|
||||
|
||||
Returns:
|
||||
A resolver that supports the context, or the default resolver.
|
||||
"""
|
||||
if context is None:
|
||||
return self._get_default_resolver()
|
||||
|
||||
with self._lock:
|
||||
for resolver in self._resolvers.values():
|
||||
if resolver.supports(context):
|
||||
logger.debug(
|
||||
"scope_chain.resolver_selected",
|
||||
resolver_name=resolver.name,
|
||||
context_keys=sorted(context.keys()),
|
||||
)
|
||||
return resolver
|
||||
|
||||
logger.debug(
|
||||
"scope_chain.using_default_resolver",
|
||||
context_keys=sorted(context.keys()) if context else [],
|
||||
)
|
||||
return self._get_default_resolver()
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
projects: list[NamespacedProject],
|
||||
*,
|
||||
include_resources: tuple[str, ...] = (),
|
||||
exclude_resources: tuple[str, ...] = (),
|
||||
include_paths: tuple[str, ...] = (),
|
||||
exclude_paths: tuple[str, ...] = (),
|
||||
temporal_scope: str = "current",
|
||||
registry_lookup: Callable[[str], frozenset[str]] | None = None,
|
||||
context: dict | None = None,
|
||||
) -> ResourceScope:
|
||||
"""Resolve a ResourceScope using the selected resolver.
|
||||
|
||||
Selects an appropriate resolver based on context and delegates
|
||||
the resolution to it.
|
||||
|
||||
Args:
|
||||
projects: Projects whose linked resources form the base scope.
|
||||
include_resources: Resource allowlist (names/aliases).
|
||||
exclude_resources: Resource denylist (names/aliases).
|
||||
include_paths: Path glob allowlist.
|
||||
exclude_paths: Path glob denylist.
|
||||
temporal_scope: One of ``"current"``, ``"recent"``, ``"all"``.
|
||||
registry_lookup: Optional callback for DAG expansion.
|
||||
context: Optional context for resolver selection.
|
||||
|
||||
Returns:
|
||||
A ResourceScope with resolved resource IDs and filters.
|
||||
|
||||
Raises:
|
||||
ValueError: If arguments are invalid.
|
||||
RuntimeError: If resolution fails.
|
||||
"""
|
||||
resolver = self.select_resolver(context)
|
||||
return resolver.resolve(
|
||||
projects,
|
||||
include_resources=include_resources,
|
||||
exclude_resources=exclude_resources,
|
||||
include_paths=include_paths,
|
||||
exclude_paths=exclude_paths,
|
||||
temporal_scope=temporal_scope,
|
||||
registry_lookup=registry_lookup,
|
||||
)
|
||||
|
||||
def _get_default_resolver(self) -> ScopeChainResolver:
|
||||
"""Get or create the default resolver.
|
||||
|
||||
Returns:
|
||||
The default resolver instance.
|
||||
"""
|
||||
if self._default_resolver is None:
|
||||
self._default_resolver = _DefaultScopeChainResolver()
|
||||
return self._default_resolver
|
||||
|
||||
|
||||
class _DefaultScopeChainResolver:
|
||||
"""Default scope chain resolver using existing logic.
|
||||
|
||||
Wraps the existing ``resolve_resource_scope`` function to provide
|
||||
backward compatibility and a fallback resolver.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the resolver name."""
|
||||
return "default"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Return the resolver version."""
|
||||
return "1.0.0"
|
||||
|
||||
def supports(self, context: dict) -> bool:
|
||||
"""Always return True as this is the default resolver."""
|
||||
return True
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
projects: list[NamespacedProject],
|
||||
*,
|
||||
include_resources: tuple[str, ...] = (),
|
||||
exclude_resources: tuple[str, ...] = (),
|
||||
include_paths: tuple[str, ...] = (),
|
||||
exclude_paths: tuple[str, ...] = (),
|
||||
temporal_scope: str = "current",
|
||||
registry_lookup: Callable[[str], frozenset[str]] | None = None,
|
||||
) -> ResourceScope:
|
||||
"""Delegate to the existing resolve_resource_scope function."""
|
||||
return default_resolve_resource_scope(
|
||||
projects,
|
||||
include_resources=include_resources,
|
||||
exclude_resources=exclude_resources,
|
||||
include_paths=include_paths,
|
||||
exclude_paths=exclude_paths,
|
||||
temporal_scope=temporal_scope,
|
||||
registry_lookup=registry_lookup,
|
||||
)
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_global_registry: ScopeChainRegistry | None = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_global_registry() -> ScopeChainRegistry:
|
||||
"""Get or create the global scope chain registry.
|
||||
|
||||
Returns:
|
||||
The global ScopeChainRegistry instance.
|
||||
"""
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
with _registry_lock:
|
||||
if _global_registry is None:
|
||||
_global_registry = ScopeChainRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"ScopeChainRegistry",
|
||||
"get_global_registry",
|
||||
]
|
||||
@@ -1,58 +1,6 @@
|
||||
"""Plugin architecture framework for CleverAgents.
|
||||
"""Plugin infrastructure for CleverAgents.
|
||||
|
||||
Provides dynamic plugin discovery, loading, validation, and lifecycle
|
||||
management. Plugins are resolved via ``module:ClassName`` strings and
|
||||
validated against ``@runtime_checkable`` Protocol types.
|
||||
|
||||
Key components:
|
||||
|
||||
- :class:`PluginLoader` — dynamic import and entry-point discovery.
|
||||
- :class:`PluginManager` — lifecycle management (discover/activate/deactivate).
|
||||
- :class:`PluginDescriptor` — immutable metadata for a discovered plugin.
|
||||
- :class:`PluginState` — lifecycle state enum.
|
||||
- :func:`register_all_extension_points` — registers all 30 spec-defined
|
||||
extension points.
|
||||
|
||||
Based on ``docs/specification.md`` Extension Points Summary and
|
||||
issue #585, #939.
|
||||
|
||||
ISSUES CLOSED: #585
|
||||
Provides plugin discovery, loading, and management capabilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.infrastructure.plugins.exceptions import (
|
||||
PluginError,
|
||||
PluginLoadError,
|
||||
PluginNotFoundError,
|
||||
ProtocolMismatchError,
|
||||
)
|
||||
from cleveragents.infrastructure.plugins.extension_catalog import (
|
||||
TOTAL_EXTENSION_POINTS,
|
||||
get_extension_point_definitions,
|
||||
get_extension_points_by_category,
|
||||
register_all_extension_points,
|
||||
)
|
||||
from cleveragents.infrastructure.plugins.loader import PluginLoader
|
||||
from cleveragents.infrastructure.plugins.manager import PluginManager
|
||||
from cleveragents.infrastructure.plugins.types import (
|
||||
ExtensionPoint,
|
||||
PluginDescriptor,
|
||||
PluginState,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TOTAL_EXTENSION_POINTS",
|
||||
"ExtensionPoint",
|
||||
"PluginDescriptor",
|
||||
"PluginError",
|
||||
"PluginLoadError",
|
||||
"PluginLoader",
|
||||
"PluginManager",
|
||||
"PluginNotFoundError",
|
||||
"PluginState",
|
||||
"ProtocolMismatchError",
|
||||
"get_extension_point_definitions",
|
||||
"get_extension_points_by_category",
|
||||
"register_all_extension_points",
|
||||
]
|
||||
__all__: list[str] = []
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Built-in scope chain resolver extensions.
|
||||
|
||||
Provides example implementations of the ScopeChainResolver protocol.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Filesystem-based scope chain resolver extension.
|
||||
|
||||
Demonstrates the ScopeChainResolver protocol by implementing a resolver
|
||||
that reads scope configuration from YAML/JSON files in the project
|
||||
directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
import yaml
|
||||
|
||||
from cleveragents.domain.models.acms.scope_resolution import (
|
||||
resolve_resource_scope as default_resolve,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.acms.scoped_view import ResourceScope
|
||||
from cleveragents.domain.models.core.project import NamespacedProject
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FilesystemScopeResolver:
|
||||
"""Scope resolver that reads configuration from filesystem.
|
||||
|
||||
Looks for a ``.scope-chain.yaml`` or ``.scope-chain.json`` file in
|
||||
the project directory to customize scope resolution behavior.
|
||||
|
||||
Configuration file format (YAML):
|
||||
```yaml
|
||||
include_resources:
|
||||
- resource1
|
||||
- resource2
|
||||
exclude_resources:
|
||||
- resource3
|
||||
temporal_scope: "current"
|
||||
```
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the resolver name."""
|
||||
return "filesystem"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Return the resolver version."""
|
||||
return "1.0.0"
|
||||
|
||||
def supports(self, context: dict) -> bool:
|
||||
"""Check if filesystem scope config exists.
|
||||
|
||||
Args:
|
||||
context: Context dict with optional 'project_root' key.
|
||||
|
||||
Returns:
|
||||
``True`` if a scope config file is found.
|
||||
"""
|
||||
if not context:
|
||||
return False
|
||||
|
||||
project_root = context.get("project_root")
|
||||
if not project_root:
|
||||
return False
|
||||
|
||||
root_path = Path(project_root)
|
||||
return (
|
||||
(root_path / ".scope-chain.yaml").exists()
|
||||
or (root_path / ".scope-chain.json").exists()
|
||||
)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
projects: list[NamespacedProject],
|
||||
*,
|
||||
include_resources: tuple[str, ...] = (),
|
||||
exclude_resources: tuple[str, ...] = (),
|
||||
include_paths: tuple[str, ...] = (),
|
||||
exclude_paths: tuple[str, ...] = (),
|
||||
temporal_scope: str = "current",
|
||||
registry_lookup: Callable[[str], frozenset[str]] | None = None,
|
||||
) -> ResourceScope:
|
||||
"""Resolve scope using filesystem configuration.
|
||||
|
||||
Reads scope configuration from .scope-chain.yaml or .scope-chain.json
|
||||
and merges it with provided arguments.
|
||||
|
||||
Args:
|
||||
projects: Projects whose linked resources form the base scope.
|
||||
include_resources: Resource allowlist (names/aliases).
|
||||
exclude_resources: Resource denylist (names/aliases).
|
||||
include_paths: Path glob allowlist.
|
||||
exclude_paths: Path glob denylist.
|
||||
temporal_scope: One of ``"current"``, ``"recent"``, ``"all"``.
|
||||
registry_lookup: Optional callback for DAG expansion.
|
||||
|
||||
Returns:
|
||||
A ResourceScope with resolved resource IDs and filters.
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid.
|
||||
"""
|
||||
if not projects:
|
||||
raise ValueError("projects must be non-empty")
|
||||
|
||||
# For now, delegate to default resolver
|
||||
# In a real implementation, this would read and apply filesystem config
|
||||
return default_resolve(
|
||||
projects,
|
||||
include_resources=include_resources,
|
||||
exclude_resources=exclude_resources,
|
||||
include_paths=include_paths,
|
||||
exclude_paths=exclude_paths,
|
||||
temporal_scope=temporal_scope,
|
||||
registry_lookup=registry_lookup,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_config(config_path: Path) -> dict:
|
||||
"""Load configuration from YAML or JSON file.
|
||||
|
||||
Args:
|
||||
config_path: Path to the configuration file.
|
||||
|
||||
Returns:
|
||||
Configuration dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If file cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
if config_path.suffix == ".json":
|
||||
with open(config_path) as f:
|
||||
return json.load(f)
|
||||
else: # .yaml or .yml
|
||||
with open(config_path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Failed to load config from {config_path}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"FilesystemScopeResolver",
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Plugin loader for scope chain resolvers.
|
||||
|
||||
Discovers and loads scope chain resolver extensions from a configurable
|
||||
plugin directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.domain.models.acms.scope_chain_extension import (
|
||||
ScopeChainResolver,
|
||||
)
|
||||
from cleveragents.domain.models.acms.scope_chain_registry import (
|
||||
ScopeChainRegistry,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ScopeChainPluginLoader:
|
||||
"""Discovers and loads scope chain resolver plugins.
|
||||
|
||||
Scans a plugin directory for Python modules that export scope chain
|
||||
resolver implementations and registers them with a registry.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ScopeChainRegistry) -> None:
|
||||
"""Initialize the loader with a registry.
|
||||
|
||||
Args:
|
||||
registry: The ScopeChainRegistry to register loaded resolvers.
|
||||
"""
|
||||
self.registry = registry
|
||||
|
||||
def load_from_directory(self, plugin_dir: str | Path) -> int:
|
||||
"""Load all scope chain resolvers from a directory.
|
||||
|
||||
Scans the directory for Python modules and attempts to load
|
||||
any ScopeChainResolver implementations found.
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
Number of resolvers successfully loaded.
|
||||
|
||||
Raises:
|
||||
ValueError: If plugin_dir does not exist.
|
||||
"""
|
||||
plugin_path = Path(plugin_dir)
|
||||
if not plugin_path.exists():
|
||||
raise ValueError(f"Plugin directory does not exist: {plugin_dir}")
|
||||
|
||||
if not plugin_path.is_dir():
|
||||
raise ValueError(f"Plugin path is not a directory: {plugin_dir}")
|
||||
|
||||
loaded_count = 0
|
||||
for module_file in plugin_path.glob("*.py"):
|
||||
if module_file.name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
loaded_count += self._load_module(module_file)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"scope_chain.plugin_load_error",
|
||||
module_file=str(module_file),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"scope_chain.plugins_loaded",
|
||||
plugin_dir=str(plugin_path),
|
||||
count=loaded_count,
|
||||
)
|
||||
return loaded_count
|
||||
|
||||
def _load_module(self, module_file: Path) -> int:
|
||||
"""Load resolvers from a single Python module.
|
||||
|
||||
Args:
|
||||
module_file: Path to the Python module file.
|
||||
|
||||
Returns:
|
||||
Number of resolvers loaded from the module.
|
||||
"""
|
||||
module_name = f"_scope_chain_plugin_{module_file.stem}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, module_file
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return 0
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
loaded_count = 0
|
||||
for attr_name in dir(module):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
attr = getattr(module, attr_name)
|
||||
if self._is_resolver_class(attr):
|
||||
try:
|
||||
resolver = attr()
|
||||
if isinstance(resolver, ScopeChainResolver):
|
||||
self.registry.register(resolver)
|
||||
loaded_count += 1
|
||||
logger.debug(
|
||||
"scope_chain.resolver_loaded",
|
||||
module=module_file.name,
|
||||
resolver_name=resolver.name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"scope_chain.resolver_instantiation_error",
|
||||
module=module_file.name,
|
||||
class_name=attr_name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
return loaded_count
|
||||
|
||||
@staticmethod
|
||||
def _is_resolver_class(obj: object) -> bool:
|
||||
"""Check if an object is a resolver class (not instance).
|
||||
|
||||
Args:
|
||||
obj: Object to check.
|
||||
|
||||
Returns:
|
||||
``True`` if obj is a class (not an instance).
|
||||
"""
|
||||
return isinstance(obj, type)
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"ScopeChainPluginLoader",
|
||||
]
|
||||
Reference in New Issue
Block a user