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

1""" 

2Argument Parser 

3 

4Provides argument parsing and validation for commands. 

5Ported from HevolveBot's command argument handling. 

6""" 

7 

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 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26class ArgumentType(Enum): 

27 """Type of argument.""" 

28 STRING = "string" 

29 INTEGER = "integer" 

30 FLOAT = "float" 

31 BOOLEAN = "boolean" 

32 CHOICE = "choice" 

33 

34 

35@dataclass 

36class ArgumentChoice: 

37 """A choice option for an argument.""" 

38 value: str 

39 label: Optional[str] = None 

40 

41 def __post_init__(self): 

42 if self.label is None: 

43 self.label = self.value 

44 

45 

46@dataclass 

47class ArgumentDefinition: 

48 """ 

49 Definition of a command argument. 

50 

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 

69 

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 

84 

85 

86@dataclass 

87class ParsedArgument: 

88 """A parsed argument value.""" 

89 name: str 

90 raw_value: str 

91 parsed_value: Any 

92 arg_type: ArgumentType 

93 

94 

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 

103 

104 def get(self, name: str, default: Any = None) -> Any: 

105 """Get a parsed argument value.""" 

106 return self.args.get(name, default) 

107 

108 def has(self, name: str) -> bool: 

109 """Check if argument was provided.""" 

110 return name in self.args 

111 

112 

113class ArgumentParser: 

114 """ 

115 Parses command arguments. 

116 

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 """ 

125 

126 def __init__(self, definitions: Optional[List[ArgumentDefinition]] = None): 

127 self.definitions = definitions or [] 

128 

129 def add_argument(self, definition: ArgumentDefinition) -> "ArgumentParser": 

130 """Add an argument definition.""" 

131 self.definitions.append(definition) 

132 return self 

133 

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 

157 

158 def parse(self, raw: Optional[str]) -> ParseResult: 

159 """ 

160 Parse arguments from raw string. 

161 

162 Args: 

163 raw: Raw argument string 

164 

165 Returns: 

166 ParseResult with parsed values 

167 """ 

168 result = ParseResult(success=True) 

169 

170 if not self.definitions: 

171 # No definitions, just return raw 

172 if raw: 

173 result.remaining = raw.strip() 

174 return result 

175 

176 # Tokenize input 

177 tokens = self._tokenize(raw) 

178 token_index = 0 

179 

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 

189 

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) 

195 

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 

202 

203 token_index = len(tokens) 

204 break 

205 

206 # Parse single token 

207 raw_value = tokens[token_index] 

208 parsed = self._parse_value(raw_value, definition) 

209 

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 

220 

221 # Handle remaining tokens 

222 if token_index < len(tokens): 

223 result.remaining = " ".join(tokens[token_index:]) 

224 

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}") 

231 

232 return result 

233 

234 def _tokenize(self, raw: Optional[str]) -> List[str]: 

235 """Tokenize raw argument string.""" 

236 if not raw: 

237 return [] 

238 

239 raw = raw.strip() 

240 if not raw: 

241 return [] 

242 

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() 

249 

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 

254 

255 # Type conversion 

256 try: 

257 if definition.arg_type == ArgumentType.STRING: 

258 parsed = raw 

259 

260 elif definition.arg_type == ArgumentType.INTEGER: 

261 parsed = int(raw) 

262 

263 elif definition.arg_type == ArgumentType.FLOAT: 

264 parsed = float(raw) 

265 

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 

274 

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 

284 

285 else: 

286 parsed = raw 

287 

288 except (ValueError, TypeError): 

289 return None 

290 

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 

301 

302 return parsed 

303 

304 def format_usage(self, command_name: str = "command") -> str: 

305 """Format usage string for the command.""" 

306 parts = [f"/{command_name}"] 

307 

308 for definition in self.definitions: 

309 if definition.required: 

310 parts.append(f"<{definition.name}>") 

311 else: 

312 parts.append(f"[{definition.name}]") 

313 

314 return " ".join(parts) 

315 

316 def format_help(self) -> str: 

317 """Format help text for arguments.""" 

318 if not self.definitions: 

319 return "No arguments." 

320 

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" 

326 

327 line = f" {definition.name}: {desc} [{type_str}]{req}" 

328 

329 if definition.choices: 

330 choices_str = ", ".join(c.value for c in definition.choices) 

331 line += f"\n Choices: {choices_str}" 

332 

333 if definition.default is not None: 

334 line += f"\n Default: {definition.default}" 

335 

336 lines.append(line) 

337 

338 return "\n".join(lines) 

339 

340 

341def create_parser(*definitions: ArgumentDefinition) -> ArgumentParser: 

342 """Create a parser with the given definitions.""" 

343 return ArgumentParser(list(definitions)) 

344 

345 

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 

359 

360 

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 

371 

372 

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