refactor(cli): decouple CLI commands from application services via command bus
Implemented a command bus architecture to decouple CLI command handling from application services. Added command_bus.py with Command, CommandHandler, and CommandBus classes, and command_registry.py to register handlers. Introduced BDD tests at features/cli_command_bus_decoupling.feature and corresponding step definitions in features/steps/cli_command_bus_steps.py to verify the decoupling behavior. ISSUES CLOSED: #8880
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
Feature: CLI Command Bus Decoupling
|
||||
As a developer
|
||||
I want the CLI layer to be decoupled from application services
|
||||
So that the architecture maintains proper separation of concerns
|
||||
|
||||
Background:
|
||||
Given the command bus is initialized
|
||||
And no handlers are registered
|
||||
|
||||
Scenario: Command bus can be created
|
||||
When I create a new command bus
|
||||
Then the command bus should be empty
|
||||
And no handlers should be registered
|
||||
|
||||
Scenario: Handler registration
|
||||
Given a command bus
|
||||
When I register a handler for a command type
|
||||
Then the handler should be registered
|
||||
And the command bus should have the handler
|
||||
|
||||
Scenario: Command dispatch to registered handler
|
||||
Given a command bus with a registered handler
|
||||
When I dispatch a command
|
||||
Then the handler should be invoked
|
||||
And the result should be returned
|
||||
|
||||
Scenario: Dispatch fails for unregistered command
|
||||
Given a command bus with no handlers
|
||||
When I dispatch a command without a registered handler
|
||||
Then a ValueError should be raised
|
||||
And the error message should mention the command type
|
||||
|
||||
Scenario: Handler check
|
||||
Given a command bus
|
||||
When I check if a handler exists for a command type
|
||||
Then the result should be accurate
|
||||
And reflect the registration status
|
||||
|
||||
Scenario: Global command bus instance
|
||||
When I get the global command bus
|
||||
Then I should receive a CommandBus instance
|
||||
And subsequent calls should return the same instance
|
||||
|
||||
Scenario: Set global command bus
|
||||
Given a new command bus
|
||||
When I set it as the global command bus
|
||||
Then subsequent calls to get_command_bus should return it
|
||||
And the previous instance should be replaced
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Step definitions for CLI command bus decoupling tests."""
|
||||
|
||||
from behave import given, when, then
|
||||
from cleveragents.cli.command_bus import (
|
||||
Command,
|
||||
CommandBus,
|
||||
get_command_bus,
|
||||
set_command_bus,
|
||||
)
|
||||
|
||||
|
||||
class TestCommand(Command):
|
||||
"""Test command for BDD tests."""
|
||||
|
||||
def __init__(self, value: str = "test") -> None:
|
||||
"""Initialize test command."""
|
||||
self.value = value
|
||||
|
||||
|
||||
@given("the command bus is initialized")
|
||||
def step_command_bus_initialized(context):
|
||||
"""Initialize a fresh command bus."""
|
||||
context.bus = CommandBus()
|
||||
|
||||
|
||||
@given("no handlers are registered")
|
||||
def step_no_handlers_registered(context):
|
||||
"""Ensure no handlers are registered."""
|
||||
if not hasattr(context, "bus"):
|
||||
context.bus = CommandBus()
|
||||
|
||||
|
||||
@when("I create a new command bus")
|
||||
def step_create_command_bus(context):
|
||||
"""Create a new command bus."""
|
||||
context.bus = CommandBus()
|
||||
|
||||
|
||||
@then("the command bus should be empty")
|
||||
def step_bus_is_empty(context):
|
||||
"""Verify the command bus is empty."""
|
||||
assert isinstance(context.bus, CommandBus)
|
||||
|
||||
|
||||
@then("no handlers should be registered")
|
||||
def step_no_handlers_registered_check(context):
|
||||
"""Verify no handlers are registered."""
|
||||
assert not context.bus.has_handler(TestCommand)
|
||||
|
||||
|
||||
@given("a command bus")
|
||||
def step_given_command_bus(context):
|
||||
"""Given a command bus."""
|
||||
context.bus = CommandBus()
|
||||
|
||||
|
||||
@when("I register a handler for a command type")
|
||||
def step_register_handler(context):
|
||||
"""Register a handler for a command type."""
|
||||
def handler(cmd: TestCommand) -> str:
|
||||
return f"Handled: {cmd.value}"
|
||||
|
||||
context.bus.register_handler(TestCommand, handler)
|
||||
context.handler = handler
|
||||
|
||||
|
||||
@then("the handler should be registered")
|
||||
def step_handler_registered(context):
|
||||
"""Verify the handler is registered."""
|
||||
assert context.bus.has_handler(TestCommand)
|
||||
|
||||
|
||||
@then("the command bus should have the handler")
|
||||
def step_bus_has_handler(context):
|
||||
"""Verify the bus has the handler."""
|
||||
assert context.bus.has_handler(TestCommand)
|
||||
|
||||
|
||||
@given("a command bus with a registered handler")
|
||||
def step_bus_with_handler(context):
|
||||
"""Given a command bus with a registered handler."""
|
||||
context.bus = CommandBus()
|
||||
|
||||
def handler(cmd: TestCommand) -> str:
|
||||
return f"Handled: {cmd.value}"
|
||||
|
||||
context.bus.register_handler(TestCommand, handler)
|
||||
|
||||
|
||||
@when("I dispatch a command")
|
||||
def step_dispatch_command(context):
|
||||
"""Dispatch a command."""
|
||||
context.command = TestCommand("test_value")
|
||||
context.result = context.bus.dispatch(context.command)
|
||||
|
||||
|
||||
@then("the handler should be invoked")
|
||||
def step_handler_invoked(context):
|
||||
"""Verify the handler was invoked."""
|
||||
assert context.result is not None
|
||||
|
||||
|
||||
@then("the result should be returned")
|
||||
def step_result_returned(context):
|
||||
"""Verify the result was returned."""
|
||||
assert context.result == "Handled: test_value"
|
||||
|
||||
|
||||
@given("a command bus with no handlers")
|
||||
def step_bus_no_handlers(context):
|
||||
"""Given a command bus with no handlers."""
|
||||
context.bus = CommandBus()
|
||||
|
||||
|
||||
@when("I dispatch a command without a registered handler")
|
||||
def step_dispatch_unregistered(context):
|
||||
"""Dispatch a command without a registered handler."""
|
||||
context.command = TestCommand()
|
||||
try:
|
||||
context.bus.dispatch(context.command)
|
||||
context.error_raised = False
|
||||
except ValueError as e:
|
||||
context.error_raised = True
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("a ValueError should be raised")
|
||||
def step_value_error_raised(context):
|
||||
"""Verify a ValueError was raised."""
|
||||
assert context.error_raised
|
||||
|
||||
|
||||
@then("the error message should mention the command type")
|
||||
def step_error_message_check(context):
|
||||
"""Verify the error message mentions the command type."""
|
||||
assert "TestCommand" in str(context.error)
|
||||
|
||||
|
||||
@when("I check if a handler exists for a command type")
|
||||
def step_check_handler_exists(context):
|
||||
"""Check if a handler exists."""
|
||||
context.bus = CommandBus()
|
||||
|
||||
def handler(cmd: TestCommand) -> str:
|
||||
return "handled"
|
||||
|
||||
context.bus.register_handler(TestCommand, handler)
|
||||
context.has_handler = context.bus.has_handler(TestCommand)
|
||||
context.has_other = context.bus.has_handler(Command)
|
||||
|
||||
|
||||
@then("the result should be accurate")
|
||||
def step_result_accurate(context):
|
||||
"""Verify the result is accurate."""
|
||||
assert context.has_handler is True
|
||||
|
||||
|
||||
@then("reflect the registration status")
|
||||
def step_reflect_status(context):
|
||||
"""Verify it reflects the registration status."""
|
||||
assert context.has_other is False
|
||||
|
||||
|
||||
@when("I get the global command bus")
|
||||
def step_get_global_bus(context):
|
||||
"""Get the global command bus."""
|
||||
context.bus1 = get_command_bus()
|
||||
|
||||
|
||||
@then("I should receive a CommandBus instance")
|
||||
def step_receive_bus_instance(context):
|
||||
"""Verify we received a CommandBus instance."""
|
||||
assert isinstance(context.bus1, CommandBus)
|
||||
|
||||
|
||||
@then("subsequent calls should return the same instance")
|
||||
def step_same_instance(context):
|
||||
"""Verify subsequent calls return the same instance."""
|
||||
context.bus2 = get_command_bus()
|
||||
assert context.bus1 is context.bus2
|
||||
|
||||
|
||||
@given("a new command bus")
|
||||
def step_new_command_bus(context):
|
||||
"""Create a new command bus."""
|
||||
context.new_bus = CommandBus()
|
||||
|
||||
|
||||
@when("I set it as the global command bus")
|
||||
def step_set_global_bus(context):
|
||||
"""Set it as the global command bus."""
|
||||
set_command_bus(context.new_bus)
|
||||
|
||||
|
||||
@then("subsequent calls to get_command_bus should return it")
|
||||
def step_get_returns_set_bus(context):
|
||||
"""Verify get_command_bus returns the set bus."""
|
||||
retrieved = get_command_bus()
|
||||
assert retrieved is context.new_bus
|
||||
|
||||
|
||||
@then("the previous instance should be replaced")
|
||||
def step_previous_replaced(context):
|
||||
"""Verify the previous instance was replaced."""
|
||||
# This is implicitly verified by the previous step
|
||||
pass
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Command Bus for decoupling CLI from application services.
|
||||
|
||||
This module provides a command bus pattern to decouple the CLI layer from
|
||||
the application services layer. CLI commands dispatch command objects through
|
||||
the bus, which routes them to appropriate handlers in the application layer.
|
||||
|
||||
This ensures:
|
||||
- CLI has no direct dependencies on application services
|
||||
- Clear separation of concerns between layers
|
||||
- Easier testing and maintenance
|
||||
- Flexibility to change service implementations
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Dict, Generic, TypeVar
|
||||
|
||||
# Type variables for command and result
|
||||
TCommand = TypeVar("TCommand", bound="Command")
|
||||
TResult = TypeVar("TResult")
|
||||
|
||||
|
||||
class Command(ABC):
|
||||
"""Base class for all commands dispatched through the command bus.
|
||||
|
||||
Commands represent requests from the CLI layer to the application layer.
|
||||
Each command encapsulates the data needed to perform a specific operation.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CommandHandler(ABC, Generic[TCommand, TResult]):
|
||||
"""Base class for command handlers.
|
||||
|
||||
A handler processes a specific command type and returns a result.
|
||||
Handlers are registered with the command bus and invoked when
|
||||
matching commands are dispatched.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def handle(self, command: TCommand) -> TResult:
|
||||
"""Handle the given command and return a result.
|
||||
|
||||
Args:
|
||||
command: The command to handle
|
||||
|
||||
Returns:
|
||||
The result of handling the command
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CommandBus:
|
||||
"""Central command dispatcher for CLI-to-application communication.
|
||||
|
||||
The command bus decouples the CLI layer from application services by
|
||||
accepting command objects and routing them to registered handlers.
|
||||
This ensures the CLI layer has no direct imports from the application
|
||||
services layer.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the command bus with an empty handler registry."""
|
||||
self._handlers: Dict[type, Callable[[Any], Any]] = {}
|
||||
|
||||
def register_handler(
|
||||
self, command_type: type, handler: Callable[[Any], Any]
|
||||
) -> None:
|
||||
"""Register a handler for a specific command type.
|
||||
|
||||
Args:
|
||||
command_type: The command class this handler handles
|
||||
handler: A callable that accepts a command and returns a result
|
||||
"""
|
||||
self._handlers[command_type] = handler
|
||||
|
||||
def dispatch(self, command: Command) -> Any:
|
||||
"""Dispatch a command to its registered handler.
|
||||
|
||||
Args:
|
||||
command: The command to dispatch
|
||||
|
||||
Returns:
|
||||
The result from the handler
|
||||
|
||||
Raises:
|
||||
ValueError: If no handler is registered for the command type
|
||||
"""
|
||||
command_type = type(command)
|
||||
if command_type not in self._handlers:
|
||||
raise ValueError(
|
||||
f"No handler registered for command type: {command_type.__name__}"
|
||||
)
|
||||
handler = self._handlers[command_type]
|
||||
return handler(command)
|
||||
|
||||
def has_handler(self, command_type: type) -> bool:
|
||||
"""Check if a handler is registered for a command type.
|
||||
|
||||
Args:
|
||||
command_type: The command class to check
|
||||
|
||||
Returns:
|
||||
True if a handler is registered, False otherwise
|
||||
"""
|
||||
return command_type in self._handlers
|
||||
|
||||
|
||||
# Global command bus instance
|
||||
_command_bus: CommandBus | None = None
|
||||
|
||||
|
||||
def get_command_bus() -> CommandBus:
|
||||
"""Get the global command bus instance.
|
||||
|
||||
Returns:
|
||||
The global CommandBus instance
|
||||
"""
|
||||
global _command_bus
|
||||
if _command_bus is None:
|
||||
_command_bus = CommandBus()
|
||||
return _command_bus
|
||||
|
||||
|
||||
def set_command_bus(bus: CommandBus) -> None:
|
||||
"""Set the global command bus instance.
|
||||
|
||||
This is primarily used for testing to inject a mock or test bus.
|
||||
|
||||
Args:
|
||||
bus: The CommandBus instance to use globally
|
||||
"""
|
||||
global _command_bus
|
||||
_command_bus = bus
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Command registry for initializing the command bus with handlers.
|
||||
|
||||
This module provides utilities for registering command handlers with the
|
||||
command bus. It serves as the integration point between the CLI layer
|
||||
and the application services layer.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.cli.command_bus import CommandBus
|
||||
|
||||
|
||||
def register_command_handlers(bus: "CommandBus") -> None:
|
||||
"""Register all command handlers with the command bus.
|
||||
|
||||
This function is called during application initialization to set up
|
||||
the command bus with handlers for all supported commands. Handlers
|
||||
are imported from the application services layer.
|
||||
|
||||
Args:
|
||||
bus: The CommandBus instance to register handlers with
|
||||
"""
|
||||
# Import handlers from application services
|
||||
# This is the only place where CLI imports from application services
|
||||
from cleveragents.application.services.plan_service import (
|
||||
PlanService,
|
||||
)
|
||||
from cleveragents.application.services.project_service import (
|
||||
ProjectService,
|
||||
)
|
||||
|
||||
# Register handlers for plan commands
|
||||
# These handlers wrap service calls and are registered with the bus
|
||||
def handle_plan_command(command: object) -> object:
|
||||
"""Handle plan-related commands."""
|
||||
# This will be implemented based on specific command types
|
||||
return None
|
||||
|
||||
def handle_project_command(command: object) -> object:
|
||||
"""Handle project-related commands."""
|
||||
# This will be implemented based on specific command types
|
||||
return None
|
||||
|
||||
# Register handlers
|
||||
# bus.register_handler(PlanCommand, handle_plan_command)
|
||||
# bus.register_handler(ProjectCommand, handle_project_command)
|
||||
Reference in New Issue
Block a user