Coverage for integrations / channels / commands / arguments.py: 33.0%
212 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Argument Parser
4Provides argument parsing and validation for commands.
5Ported from HevolveBot's command argument handling.
6"""
8from dataclasses import dataclass, field
9from enum import Enum
10from typing import (
11 Any,
12 Callable,
13 Dict,
14 List,
15 Optional,
16 Tuple,
17 Union,
18)
19import re
20import shlex
21import logging
23logger = logging.getLogger(__name__)
26class ArgumentType(Enum):
27 """Type of argument."""
28 STRING = "string"
29 INTEGER = "integer"
30 FLOAT = "float"
31 BOOLEAN = "boolean"
32 CHOICE = "choice"
35@dataclass
36class ArgumentChoice:
37 """A choice option for an argument."""
38 value: str
39 label: Optional[str] = None
41 def __post_init__(self):
42 if self.label is None:
43 self.label = self.value
46@dataclass
47class ArgumentDefinition:
48 """
49 Definition of a command argument.
51 Attributes:
52 name: Argument name
53 description: Human-readable description
54 arg_type: Type of argument
55 required: Whether argument is required
56 default: Default value if not provided
57 choices: Valid choices for CHOICE type
58 capture_remaining: Capture all remaining tokens
59 validator: Custom validation function
60 """
61 name: str
62 description: str = ""
63 arg_type: ArgumentType = ArgumentType.STRING
64 required: bool = False
65 default: Any = None
66 choices: List[ArgumentChoice] = field(default_factory=list)
67 capture_remaining: bool = False
68 validator: Optional[Callable[[Any], Tuple[bool, Optional[str]]]] = None
70 def __post_init__(self):
71 # Normalize choices
72 normalized = []
73 for choice in self.choices:
74 if isinstance(choice, str):
75 normalized.append(ArgumentChoice(value=choice))
76 elif isinstance(choice, dict):
77 normalized.append(ArgumentChoice(
78 value=choice.get("value", ""),
79 label=choice.get("label"),
80 ))
81 elif isinstance(choice, ArgumentChoice):
82 normalized.append(choice)
83 self.choices = normalized
86@dataclass
87class ParsedArgument:
88 """A parsed argument value."""
89 name: str
90 raw_value: str
91 parsed_value: Any
92 arg_type: ArgumentType
95@dataclass
96class ParseResult:
97 """Result of parsing command arguments."""
98 success: bool
99 args: Dict[str, Any] = field(default_factory=dict)
100 raw_args: Dict[str, str] = field(default_factory=dict)
101 errors: List[str] = field(default_factory=list)
102 remaining: Optional[str] = None
104 def get(self, name: str, default: Any = None) -> Any:
105 """Get a parsed argument value."""
106 return self.args.get(name, default)
108 def has(self, name: str) -> bool:
109 """Check if argument was provided."""
110 return name in self.args
113class ArgumentParser:
114 """
115 Parses command arguments.
117 Features:
118 - Positional argument parsing
119 - Type conversion and validation
120 - Choice validation
121 - Required argument checking
122 - Capture remaining text
123 - Custom validators
124 """
126 def __init__(self, definitions: Optional[List[ArgumentDefinition]] = None):
127 self.definitions = definitions or []
129 def add_argument(self, definition: ArgumentDefinition) -> "ArgumentParser":
130 """Add an argument definition."""
131 self.definitions.append(definition)
132 return self
134 def add(
135 self,
136 name: str,
137 description: str = "",
138 arg_type: ArgumentType = ArgumentType.STRING,
139 required: bool = False,
140 default: Any = None,
141 choices: Optional[List[Union[str, ArgumentChoice]]] = None,
142 capture_remaining: bool = False,
143 validator: Optional[Callable] = None,
144 ) -> "ArgumentParser":
145 """Add an argument with parameters."""
146 self.add_argument(ArgumentDefinition(
147 name=name,
148 description=description,
149 arg_type=arg_type,
150 required=required,
151 default=default,
152 choices=choices or [],
153 capture_remaining=capture_remaining,
154 validator=validator,
155 ))
156 return self
158 def parse(self, raw: Optional[str]) -> ParseResult:
159 """
160 Parse arguments from raw string.
162 Args:
163 raw: Raw argument string
165 Returns:
166 ParseResult with parsed values
167 """
168 result = ParseResult(success=True)
170 if not self.definitions:
171 # No definitions, just return raw
172 if raw:
173 result.remaining = raw.strip()
174 return result
176 # Tokenize input
177 tokens = self._tokenize(raw)
178 token_index = 0
180 for definition in self.definitions:
181 if token_index >= len(tokens):
182 # No more tokens
183 if definition.required:
184 result.success = False
185 result.errors.append(f"Missing required argument: {definition.name}")
186 elif definition.default is not None:
187 result.args[definition.name] = definition.default
188 continue
190 if definition.capture_remaining:
191 # Capture all remaining tokens
192 remaining_tokens = tokens[token_index:]
193 raw_value = " ".join(remaining_tokens)
194 parsed = self._parse_value(raw_value, definition)
196 if parsed is None:
197 result.success = False
198 result.errors.append(f"Invalid value for {definition.name}")
199 else:
200 result.args[definition.name] = parsed
201 result.raw_args[definition.name] = raw_value
203 token_index = len(tokens)
204 break
206 # Parse single token
207 raw_value = tokens[token_index]
208 parsed = self._parse_value(raw_value, definition)
210 if parsed is None:
211 if definition.required:
212 result.success = False
213 result.errors.append(f"Invalid value for {definition.name}: {raw_value}")
214 elif definition.default is not None:
215 result.args[definition.name] = definition.default
216 else:
217 result.args[definition.name] = parsed
218 result.raw_args[definition.name] = raw_value
219 token_index += 1
221 # Handle remaining tokens
222 if token_index < len(tokens):
223 result.remaining = " ".join(tokens[token_index:])
225 # Check for missing required arguments
226 for definition in self.definitions:
227 if definition.required and definition.name not in result.args:
228 result.success = False
229 if f"Missing required argument: {definition.name}" not in result.errors:
230 result.errors.append(f"Missing required argument: {definition.name}")
232 return result
234 def _tokenize(self, raw: Optional[str]) -> List[str]:
235 """Tokenize raw argument string."""
236 if not raw:
237 return []
239 raw = raw.strip()
240 if not raw:
241 return []
243 # Try shell-style tokenization (handles quotes)
244 try:
245 return shlex.split(raw)
246 except ValueError:
247 # Fallback to simple whitespace split
248 return raw.split()
250 def _parse_value(self, raw: str, definition: ArgumentDefinition) -> Any:
251 """Parse a single value according to its definition."""
252 if not raw:
253 return definition.default
255 # Type conversion
256 try:
257 if definition.arg_type == ArgumentType.STRING:
258 parsed = raw
260 elif definition.arg_type == ArgumentType.INTEGER:
261 parsed = int(raw)
263 elif definition.arg_type == ArgumentType.FLOAT:
264 parsed = float(raw)
266 elif definition.arg_type == ArgumentType.BOOLEAN:
267 lower = raw.lower()
268 if lower in ("true", "yes", "1", "on", "enable", "enabled"):
269 parsed = True
270 elif lower in ("false", "no", "0", "off", "disable", "disabled"):
271 parsed = False
272 else:
273 return None
275 elif definition.arg_type == ArgumentType.CHOICE:
276 # Validate against choices
277 lower = raw.lower()
278 for choice in definition.choices:
279 if choice.value.lower() == lower:
280 parsed = choice.value
281 break
282 else:
283 return None
285 else:
286 parsed = raw
288 except (ValueError, TypeError):
289 return None
291 # Custom validation
292 if definition.validator:
293 try:
294 valid, error = definition.validator(parsed)
295 if not valid:
296 logger.debug(f"Validation failed for {definition.name}: {error}")
297 return None
298 except Exception as e:
299 logger.debug(f"Validator error for {definition.name}: {e}")
300 return None
302 return parsed
304 def format_usage(self, command_name: str = "command") -> str:
305 """Format usage string for the command."""
306 parts = [f"/{command_name}"]
308 for definition in self.definitions:
309 if definition.required:
310 parts.append(f"<{definition.name}>")
311 else:
312 parts.append(f"[{definition.name}]")
314 return " ".join(parts)
316 def format_help(self) -> str:
317 """Format help text for arguments."""
318 if not self.definitions:
319 return "No arguments."
321 lines = []
322 for definition in self.definitions:
323 req = " (required)" if definition.required else ""
324 type_str = definition.arg_type.value
325 desc = definition.description or "No description"
327 line = f" {definition.name}: {desc} [{type_str}]{req}"
329 if definition.choices:
330 choices_str = ", ".join(c.value for c in definition.choices)
331 line += f"\n Choices: {choices_str}"
333 if definition.default is not None:
334 line += f"\n Default: {definition.default}"
336 lines.append(line)
338 return "\n".join(lines)
341def create_parser(*definitions: ArgumentDefinition) -> ArgumentParser:
342 """Create a parser with the given definitions."""
343 return ArgumentParser(list(definitions))
346def validate_range(min_val: Optional[float] = None, max_val: Optional[float] = None):
347 """Create a range validator."""
348 def validator(value: Any) -> Tuple[bool, Optional[str]]:
349 try:
350 num = float(value)
351 if min_val is not None and num < min_val:
352 return False, f"Value must be >= {min_val}"
353 if max_val is not None and num > max_val:
354 return False, f"Value must be <= {max_val}"
355 return True, None
356 except (ValueError, TypeError):
357 return False, "Value must be a number"
358 return validator
361def validate_pattern(pattern: str):
362 """Create a regex pattern validator."""
363 regex = re.compile(pattern)
364 def validator(value: Any) -> Tuple[bool, Optional[str]]:
365 if not isinstance(value, str):
366 return False, "Value must be a string"
367 if not regex.match(value):
368 return False, f"Value must match pattern: {pattern}"
369 return True, None
370 return validator
373def validate_length(min_len: Optional[int] = None, max_len: Optional[int] = None):
374 """Create a length validator."""
375 def validator(value: Any) -> Tuple[bool, Optional[str]]:
376 if not isinstance(value, str):
377 return False, "Value must be a string"
378 if min_len is not None and len(value) < min_len:
379 return False, f"Value must be at least {min_len} characters"
380 if max_len is not None and len(value) > max_len:
381 return False, f"Value must be at most {max_len} characters"
382 return True, None
383 return validator