diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index c94206ee45c..68eba79780f 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -1,8 +1,34 @@ -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod from typing import List -class BaseCommand(ABC): +class CommandMeta(ABCMeta): + """Metaclass for validating command classes at definition time.""" + + def __new__(mcs, name, bases, namespace): + # Create the class first + cls = super().__new__(mcs, name, bases, namespace) + + # Skip validation for BaseCommand itself + if name == "BaseCommand": + return cls + + if not name.endswith("Command"): + raise TypeError(f"Command class must end with 'Command', got '{name}'") + + if getattr(cls, "NORM_NAME", None) is None: + raise TypeError("Command class must define NORM_NAME") + + if getattr(cls, "DESCRIPTION", None) is None: + raise TypeError("Command class must define DESCRIPTION") + + if "execute" not in namespace: + raise TypeError("Command class must implement execute method") + + return cls + + +class BaseCommand(ABC, metaclass=CommandMeta): """Abstract base class for all commands.""" # Class properties (similar to BaseTool) diff --git a/tests/basic/test_custom_commands.py b/tests/basic/test_custom_commands.py new file mode 100644 index 00000000000..88c0b537400 --- /dev/null +++ b/tests/basic/test_custom_commands.py @@ -0,0 +1,64 @@ +import pytest + +from cecli.commands.utils.base_command import BaseCommand + + +class TestCommandMeta: + """Tests for the CommandMeta metaclass validation.""" + + def test_valid_custom_command_is_accepted(self): + """Test that a valid custom command class is accepted.""" + + class CustomCommand(BaseCommand): + NORM_NAME = "custom" + DESCRIPTION = "A valid custom command" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + # If we get here without exception, the test passes + assert CustomCommand.NORM_NAME == "custom" + assert CustomCommand.DESCRIPTION == "A valid custom command" + + def test_class_name_must_end_with_command(self): + """Test that class name must end with 'Command'.""" + with pytest.raises(TypeError, match="Command class must end with 'Command'"): + + class Custom(BaseCommand): + NORM_NAME = "custom" + DESCRIPTION = "An invalid custom command" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + def test_must_define_norm_name(self): + """Test that NORM_NAME must be defined.""" + with pytest.raises(TypeError, match="Command class must define NORM_NAME"): + + class CustomCommand(BaseCommand): + DESCRIPTION = "Missing NORM_NAME" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + def test_must_define_description(self): + """Test that DESCRIPTION must be defined.""" + with pytest.raises(TypeError, match="Command class must define DESCRIPTION"): + + class CustomCommand(BaseCommand): + NORM_NAME = "custom" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + pass + + def test_must_implement_execute_method(self): + """Test that execute method must be implemented.""" + with pytest.raises(TypeError, match="Command class must implement execute method"): + + class CustomCommand(BaseCommand): + NORM_NAME = "custom" + DESCRIPTION = "Missing execute method"