Skip to content

Commit

Permalink
feat: add alternative Slashedcommand
Browse files Browse the repository at this point in the history
  • Loading branch information
phil65 committed Dec 28, 2024
1 parent a450477 commit 8faad0f
Show file tree
Hide file tree
Showing 5 changed files with 505 additions and 49 deletions.
146 changes: 119 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,43 +56,135 @@ pip install slashed
## Quick Example

```python
from slashed import Command, CommandStore, CommandContext
from slashed import SlashedCommand, CommandStore, CommandContext
from slashed.completers import ChoiceCompleter

# Create a command store
# Define a command with explicit parameters
class GreetCommand(SlashedCommand):
"""Greet someone with a custom greeting."""

name = "greet"
category = "demo"

async def execute_command(
self,
ctx: CommandContext,
name: str = "World",
greeting: str = "Hello",
) -> None:
"""Greet someone.
Args:
ctx: Command context
name: Who to greet
greeting: Custom greeting to use
"""
await ctx.output.print(f"{greeting}, {name}!")

def get_completer(self) -> ChoiceCompleter:
"""Provide name suggestions."""
return ChoiceCompleter({
"World": "Default greeting target",
"Everyone": "Greet all users",
"Team": "Greet the team"
})

# Create store and register the command
store = CommandStore()
store.register_command(GreetCommand)

# Create context and execute a command
ctx = store.create_context(data=None)
await store.execute_command("greet Phil --greeting Hi", ctx)
```

## Command Definition Styles

Slashed offers two different styles for defining commands, each with its own advantages:

### Traditional Style (using Command class)

# Define a command with completion support
async def greet(ctx: CommandContext, args: list[str], kwargs: dict[str, str]) -> None:
name = args[0] if args else "World"
greeting = kwargs.get("greeting", "Hello")
await ctx.output.print(f"{greeting}, {name}!")

greet_cmd = Command(
name="greet",
description="Greet someone",
execute_func=greet,
usage="[name] --greeting <greeting>",
help_text="Greet someone with a custom greeting.\n\nExample: /greet Phil --greeting Hi",
category="demo",
completer=ChoiceCompleter({
"World": "Default greeting target",
"Everyone": "Greet all users",
"Team": "Greet the team"
})
```python
from slashed import Command, CommandContext

async def add_worker(ctx: CommandContext, args: list[str], kwargs: dict[str, str]) -> None:
"""Add a worker to the pool."""
worker_id = args[0]
host = kwargs.get("host", "localhost")
port = kwargs.get("port", "8080")
await ctx.output.print(f"Adding worker {worker_id} at {host}:{port}")

cmd = Command(
name="add-worker",
description="Add a worker to the pool",
execute_func=add_worker,
usage="<worker_id> --host <host> --port <port>",
category="workers",
)
```

# Register the command
store.register_command(greet_cmd)
#### Advantages:
- Quick to create without inheritance
- All configuration in one place
- Easier to create commands dynamically
- More flexible for simple commands
- Familiar to users of other command frameworks

# Register built-in commands (help, etc)
store.register_builtin_commands()
### Declarative Style (using SlashedCommand)

# Create context and execute a command
ctx = store.create_context(data=None)
await store.execute_command("greet Phil --greeting Hi", ctx)
```python
from slashed import SlashedCommand, CommandContext

class AddWorkerCommand(SlashedCommand):
"""Add a worker to the pool."""

name = "add-worker"
category = "workers"

async def execute_command(
self,
ctx: CommandContext,
worker_id: str, # required parameter
host: str = "localhost", # optional with default
port: int = 8080, # optional with default
) -> None:
"""Add a new worker to the pool.
Args:
ctx: Command context
worker_id: Unique worker identifier
host: Worker hostname
port: Worker port number
"""
await ctx.output.print(f"Adding worker {worker_id} at {host}:{port}")
```

#### Advantages:
- Type-safe parameter handling
- Automatic usage generation from parameters
- Help text generated from docstrings
- Better IDE support with explicit parameters
- More maintainable for complex commands
- Validates required parameters automatically
- Natural Python class structure
- Parameters are self-documenting

### When to Use Which?

Use the **traditional style** when:
- Creating simple commands with few parameters
- Generating commands dynamically
- Wanting to avoid class boilerplate
- Need maximum flexibility

Use the **declarative style** when:
- Building complex commands with many parameters
- Need type safety and parameter validation
- Want IDE support for parameters
- Documentation is important
- Working in a larger codebase


## Generic Context Example

```python
Expand Down
39 changes: 19 additions & 20 deletions src/slashed/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,20 @@ class ParsedCommand:
class BaseCommand(ABC):
"""Abstract base class for commands."""

def __init__(
self,
name: str,
description: str,
category: str = "general",
usage: str | None = None,
help_text: str | None = None,
) -> None:
self.name = name
self.description = description
self.category = category
self.usage = usage
self._help_text = help_text
name: str
"""Command name"""

description: str
"""Command description"""

category: str
"""Command category"""

usage: str | None
"""Command usage"""

_help_text: str | None
"""Optional help text"""

def get_completer(self) -> CompletionProvider | None:
"""Get completion provider for this command.
Expand Down Expand Up @@ -112,13 +113,11 @@ def __init__(
help_text: str | None = None,
completer: CompletionProvider | Callable[[], CompletionProvider] | None = None,
) -> None:
super().__init__(
name=name,
description=description,
category=category,
usage=usage,
help_text=help_text,
)
self.name = name
self.description = description
self.category = category
self.usage = usage
self._help_text = help_text
self._execute_func = execute_func
self._completer = completer

Expand Down
140 changes: 140 additions & 0 deletions src/slashed/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Declarative command system."""

from __future__ import annotations

from abc import abstractmethod
import inspect
from typing import Any

from slashed.base import BaseCommand, CommandContext
from slashed.exceptions import CommandError


class SlashedCommand(BaseCommand):
"""Base class for declarative commands.
Allows defining commands using class syntax with explicit parameters:
Example:
class AddWorkerCommand(SlashedCommand):
'''Add a new worker to the pool.'''
name = "add-worker"
category = "tools"
async def execute_command(
self,
ctx: CommandContext,
worker_id: str, # required param (no default)
host: str, # required param (no default)
port: int = 8080, # optional param (has default)
) -> None:
await ctx.output.print(f"Adding worker {worker_id} at {host}:{port}")
"""

name: str
"""Command name"""

category: str = "general"
"""Command category"""

description: str = ""
"""Optional description override"""

usage: str | None = None
"""Optional usage override"""

help_text: str = ""
"""Optional help text override"""

def __init__(self) -> None:
"""Initialize command instance."""
self.description = (
self.description or inspect.getdoc(self.__class__) or "No description"
)
self.help_text = type(self).help_text or self.description

def __init_subclass__(cls) -> None:
"""Process command class at definition time.
Validates required attributes and generates description/usage from metadata.
"""
super().__init_subclass__()

if not hasattr(cls, "name"):
msg = f"Command class {cls.__name__} must define 'name' attribute"
raise TypeError(msg)

# Get description from docstring if empty
if not cls.description:
cls.description = inspect.getdoc(cls) or "No description"

# Generate usage from execute signature if not set
if cls.usage is None:
sig = inspect.signature(cls.execute_command)
params = []
# Skip self and ctx parameters
for name, param in list(sig.parameters.items())[2:]:
if param.default == inspect.Parameter.empty:
params.append(f"<{name}>")
else:
params.append(f"[--{name} <value>]")
cls.usage = " ".join(params)

@abstractmethod
async def execute_command(
self,
ctx: CommandContext,
*args: Any,
**kwargs: Any,
) -> None:
"""Execute the command logic.
This method should be implemented with explicit parameters.
Parameters without default values are treated as required.
Args:
ctx: Command execution context
*args: Method should define explicit positional parameters
**kwargs: Method should define explicit keyword parameters
"""

async def execute(
self,
ctx: CommandContext,
args: list[str],
kwargs: dict[str, str],
) -> None:
"""Execute command by binding command-line arguments to method parameters."""
# Get concrete method's signature
method = type(self).execute_command
sig = inspect.signature(method)

# Get parameter information (skip self, ctx)
parameters = dict(list(sig.parameters.items())[2:])

# Get required parameters in order
required = [
name
for name, param in parameters.items()
if param.default == inspect.Parameter.empty
]

# Check if required args are provided either as positional or keyword
missing = [
name
for name in required
if name not in kwargs and len(args) < required.index(name) + 1
]
if missing:
msg = f"Missing required arguments: {missing}"
raise CommandError(msg)

# Validate keyword arguments
for name in kwargs:
if name not in parameters:
msg = f"Unknown argument: {name}"
raise CommandError(msg)

# Call with positional args first, then kwargs
await self.execute_command(ctx, *args, **kwargs)
9 changes: 7 additions & 2 deletions src/slashed/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import os

from slashed.base import BaseCommand
from slashed.commands import SlashedCommand


T = TypeVar("T")
Expand Down Expand Up @@ -90,15 +91,19 @@ def create_context(
meta = metadata or {}
return CommandContext(output=writer, data=data, command_store=self, metadata=meta)

def register_command(self, command: BaseCommand):
def register_command(self, command: type[SlashedCommand] | BaseCommand) -> None:
"""Register a new command.
Args:
command: Command to register
command: Command class (SlashedCommand subclass) or command instance
Raises:
ValueError: If command with same name exists
"""
# If given a class, instantiate it
if isinstance(command, type):
command = command()

if command.name in self._commands:
msg = f"Command '{command.name}' already registered"
raise ValueError(msg)
Expand Down
Loading

0 comments on commit 8faad0f

Please sign in to comment.