@@ -31,13 +31,74 @@ async def send_reply(self, message: Any, content: str) -> Any:
3131 ...
3232
3333
34+ class Validator (Protocol ):
35+ """Callable validator that may coerce or reject a value."""
36+
37+ def __call__ (self , value : Any ) -> Any :
38+ ...
39+
40+ def help_hint (self ) -> Optional [str ]: # optional, but recommended
41+ ...
42+
43+
44+ class OptionValidator :
45+ """Ensure a value is one of the allowed options (useful for enums).
46+
47+ By default the match is case-sensitive. Set ``case_insensitive`` to allow
48+ case-insensitive string comparison while still returning the original
49+ value.
50+ """
51+
52+ def __init__ (self , options : Iterable [Any ], * , case_insensitive : bool = False ) -> None :
53+ opts = list (options )
54+ if not opts :
55+ raise ValueError ("options must not be empty" )
56+ self .options = opts
57+ self .case_insensitive = case_insensitive
58+ if case_insensitive :
59+ self ._normalized_map = {self ._normalize (opt ): opt for opt in opts }
60+ else :
61+ self ._allowed = set (opts )
62+
63+ def __call__ (self , value : Any ) -> Any :
64+ if self .case_insensitive and isinstance (value , str ):
65+ normalized = self ._normalize (value )
66+ if normalized in self ._normalized_map :
67+ return self ._normalized_map [normalized ]
68+ self ._raise_error (value )
69+
70+ if not self .case_insensitive :
71+ if value in self ._allowed :
72+ return value
73+ self ._raise_error (value )
74+
75+ # If case-insensitive but the value is not a string, fall back to direct membership.
76+ if value in self .options :
77+ return value
78+ self ._raise_error (value )
79+
80+ def _raise_error (self , value : Any ) -> None :
81+ choices = ", " .join (str (opt ) for opt in self .options )
82+ raise ValueError (f"Value must be one of: { choices } (got { value } )" )
83+
84+ @staticmethod
85+ def _normalize (value : Any ) -> str :
86+ return str (value ).lower ()
87+
88+ def help_hint (self ) -> str :
89+ choices = ", " .join (str (opt ) for opt in self .options )
90+ suffix = " (case-insensitive)" if self .case_insensitive else ""
91+ return f"options: { choices } { suffix } "
92+
93+
3494@dataclass
3595class CommandArgument :
3696 name : str
3797 type : type = str
3898 required : bool = True
3999 description : str = ""
40100 multiple : bool = False # capture the remainder of args
101+ validator : Optional [Validator ] = None
41102
42103
43104@dataclass
@@ -190,9 +251,17 @@ def _parse_args(self, args_tokens: List[str], spec: CommandSpec) -> Dict[str, An
190251 def _convert_value (self , value : str , arg_spec : CommandArgument ) -> ArgType :
191252 target = arg_spec .type
192253 try :
254+ converted : ArgType
193255 if target is bool :
194- return self ._to_bool (value )
195- return target (value )
256+ converted = self ._to_bool (value )
257+ else :
258+ converted = target (value )
259+
260+ if arg_spec .validator is not None :
261+ return arg_spec .validator (converted )
262+ return converted
263+ except InvalidArgumentsError :
264+ raise
196265 except Exception as exc : # pragma: no cover - simple conversion guard
197266 raise InvalidArgumentsError (arg_spec .name , f"Invalid value for { arg_spec .name } : { value } " ) from exc
198267
@@ -263,11 +332,27 @@ def _format_spec_detail(self, spec: CommandSpec) -> str:
263332 requirement = "required" if arg .required else "optional"
264333 multi = " (multiple)" if arg .multiple else ""
265334 desc = f" - { arg .name } : { requirement } { multi } "
335+ validator_hint = self ._format_validator_hint (arg .validator )
266336 if arg .description :
267337 desc += f" — { arg .description } "
338+ if validator_hint :
339+ desc += f" [{ validator_hint } ]"
268340 lines .append (desc )
269341 return "\n " .join (lines )
270342
343+ @staticmethod
344+ def _format_validator_hint (validator : Optional [Validator ]) -> str :
345+ if validator is None :
346+ return ""
347+ hint_fn = getattr (validator , "help_hint" , None )
348+ if callable (hint_fn ):
349+ try :
350+ hint = hint_fn ()
351+ return str (hint ) if hint else ""
352+ except Exception : # pragma: no cover - help rendering should not break help
353+ return ""
354+ return f"validated by { validator .__class__ .__name__ } "
355+
271356
272357__all__ = [
273358 "CommandParser" ,
@@ -277,4 +362,6 @@ def _format_spec_detail(self, spec: CommandSpec) -> str:
277362 "CommandError" ,
278363 "UnknownCommandError" ,
279364 "InvalidArgumentsError" ,
365+ "Validator" ,
366+ "OptionValidator" ,
280367]
0 commit comments