Coverage for muutils / cli / arg_bool.py: 83%

84 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-18 02:51 -0700

1from __future__ import annotations 

2 

3import argparse 

4import sys 

5from collections.abc import Iterable, Sequence 

6from typing import Any, Callable, Final, TypeVar 

7 

8if sys.version_info >= (3, 12): 

9 from typing import override 

10else: 

11 from typing_extensions import override 

12 

13T_callable = TypeVar("T_callable", bound=Callable[..., Any]) 

14 

15 

16def format_function_docstring( 

17 mapping: dict[str, Any], 

18 /, 

19) -> Callable[[T_callable], T_callable]: 

20 """Decorator to format function docstring with the given keyword arguments""" 

21 

22 # I think we don't need to use functools.wraps here, since we return the same function 

23 def decorator(func: T_callable) -> T_callable: 

24 assert func.__doc__ is not None, "Function must have a docstring to format." 

25 func.__doc__ = func.__doc__.format_map(mapping) 

26 return func 

27 

28 return decorator 

29 

30 

31# Default token sets (lowercase). You can override per-option. 

32TRUE_SET_DEFAULT: Final[set[str]] = {"1", "true", "t", "yes", "y", "on"} 

33FALSE_SET_DEFAULT: Final[set[str]] = {"0", "false", "f", "no", "n", "off"} 

34 

35 

36def _normalize_set(tokens: Iterable[str] | None, fallback: set[str]) -> set[str]: 

37 """Normalize a collection of tokens to a lowercase set, or return fallback.""" 

38 if tokens is None: 

39 return set(fallback) 

40 return {str(t).lower() for t in tokens} 

41 

42 

43def parse_bool_token( 

44 token: str, 

45 true_set: set[str] | None = None, 

46 false_set: set[str] | None = None, 

47) -> bool: 

48 """Strict string-to-bool converter for argparse and friends. 

49 

50 # Parameters: 

51 - `token : str` 

52 input token 

53 - `true_set : set[str] | None` 

54 accepted truthy strings (case-insensitive). 

55 Defaults to TRUE_SET_DEFAULT when None. 

56 - `false_set : set[str] | None` 

57 accepted falsy strings (case-insensitive). 

58 Defaults to FALSE_SET_DEFAULT when None. 

59 

60 # Returns: 

61 - `bool` 

62 parsed boolean 

63 

64 # Raises: 

65 - `argparse.ArgumentTypeError` : if not a recognized boolean string 

66 """ 

67 ts: set[str] = _normalize_set(true_set, TRUE_SET_DEFAULT) 

68 fs: set[str] = _normalize_set(false_set, FALSE_SET_DEFAULT) 

69 v: str = token.lower() 

70 if v in ts: 

71 return True 

72 if v in fs: 

73 return False 

74 valid: list[str] = sorted(ts | fs) 

75 raise argparse.ArgumentTypeError(f"expected one of {valid}") 

76 

77 

78class BoolFlagOrValue(argparse.Action): 

79 """summary 

80 

81 Configurable boolean action supporting any combination of: 

82 --flag -> True (if allow_bare) 

83 --no-flag -> False (if allow_no and --no-flag is registered) 

84 --flag true|false -> parsed via custom sets 

85 --flag=true|false -> parsed via custom sets 

86 

87 Notes: 

88 - The --no-flag form never accepts a value. It forces False. 

89 - If allow_no is False but you still register a --no-flag alias, 

90 using it will produce a usage error. 

91 - Do not pass type= to this action. 

92 

93 # Parameters: 

94 - `option_strings : list[str]` 

95 provided by argparse 

96 - `dest : str` 

97 attribute name on the namespace 

98 - `nargs : int | str | None` 

99 must be '?' for optional value 

100 - `true_set : set[str] | None` 

101 accepted truthy strings (case-insensitive). Defaults provided. 

102 - `false_set : set[str] | None` 

103 accepted falsy strings (case-insensitive). Defaults provided. 

104 - `allow_no : bool` 

105 whether the --no-flag form is allowed (defaults to True) 

106 - `allow_bare : bool` 

107 whether bare --flag (no value) is allowed (defaults to True) 

108 - `**kwargs` 

109 forwarded to base class 

110 

111 # Raises: 

112 - `ValueError` : if nargs is not '?' or if type= is provided 

113 """ 

114 

115 def __init__( 

116 self, 

117 option_strings: Sequence[str], 

118 dest: str, 

119 nargs: int | str | None = None, 

120 true_set: set[str] | None = None, 

121 false_set: set[str] | None = None, 

122 allow_no: bool = True, 

123 allow_bare: bool = True, 

124 **kwargs: Any, 

125 ) -> None: 

126 if "type" in kwargs and kwargs["type"] is not None: 

127 raise ValueError("BoolFlagOrValue does not accept type=. Remove it.") 

128 

129 if nargs not in (None, "?"): 

130 raise ValueError("BoolFlagOrValue requires nargs='?'") 

131 

132 super().__init__( 

133 option_strings=option_strings, 

134 dest=dest, 

135 nargs="?", 

136 **kwargs, 

137 ) 

138 # Store normalized config 

139 self.true_set: set[str] = _normalize_set(true_set, TRUE_SET_DEFAULT) 

140 self.false_set: set[str] = _normalize_set(false_set, FALSE_SET_DEFAULT) 

141 self.allow_no: bool = allow_no 

142 self.allow_bare: bool = allow_bare 

143 

144 def _parse_token(self, token: str) -> bool: 

145 """Parse a boolean token using this action's configured sets.""" 

146 return parse_bool_token(token, self.true_set, self.false_set) 

147 

148 @override 

149 def __call__( 

150 self, 

151 parser: argparse.ArgumentParser, 

152 namespace: argparse.Namespace, 

153 values: str | Sequence[str] | None, 

154 option_string: str | None = None, 

155 ) -> None: 

156 # Negated form handling 

157 if option_string is not None and option_string.startswith("--no-"): 

158 if not self.allow_no: 

159 parser.error(f"{option_string} is not allowed for this option") 

160 return 

161 if values is not None: 

162 dest_flag: str = self.dest.replace("_", "-") 

163 parser.error( 

164 f"{option_string} does not take a value; use --{dest_flag} true|false" 

165 ) 

166 return 

167 setattr(namespace, self.dest, False) 

168 return 

169 

170 # Bare positive flag -> True (if allowed) 

171 if values is None: 

172 if not self.allow_bare: 

173 valid: list[str] = sorted(self.true_set | self.false_set) 

174 parser.error( 

175 f"option {option_string} requires a value; expected one of {valid}" 

176 ) 

177 return 

178 setattr(namespace, self.dest, True) 

179 return 

180 

181 # we take only one value 

182 if not isinstance(values, str): 

183 if len(values) != 1: 

184 parser.error( 

185 f"{option_string} expects a single value, got {len(values) = }, {values = }" 

186 ) 

187 return 

188 values = values[0] # type: ignore[assignment] 

189 

190 # Positive flag with explicit value -> parse 

191 try: 

192 val: bool = self._parse_token(values) 

193 except argparse.ArgumentTypeError as e: 

194 parser.error(str(e)) 

195 return 

196 setattr(namespace, self.dest, val) 

197 

198 

199def add_bool_flag( 

200 parser: argparse.ArgumentParser, 

201 name: str, 

202 *, 

203 default: bool = False, 

204 help: str = "", 

205 true_set: set[str] | None = None, 

206 false_set: set[str] | None = None, 

207 allow_no: bool = False, 

208 allow_bare: bool = True, 

209) -> None: 

210 """summary 

211 

212 Add a configurable boolean option that supports (depending on options): 

213 --<name> (bare positive, if allow_bare) 

214 --no-<name> (negated, if allow_no) 

215 --<name> true|false 

216 --<name>=true|false 

217 

218 # Parameters: 

219 - `parser : argparse.ArgumentParser` 

220 parser to modify 

221 - `name : str` 

222 base long option name (without leading dashes) 

223 - `default : bool` 

224 default value (defaults to False) 

225 - `help : str` 

226 help text (optional) 

227 - `true_set : set[str] | None` 

228 accepted truthy strings (case-insensitive). Defaults used when None. 

229 - `false_set : set[str] | None` 

230 accepted falsy strings (case-insensitive). Defaults used when None. 

231 - `allow_no : bool` 

232 whether to register/allow the --no-<name> alias (defaults to True) 

233 - `allow_bare : bool` 

234 whether bare --<name> implies True (defaults to True) 

235 

236 # Returns: 

237 - `None` 

238 nothing; parser is modified 

239 

240 # Modifies: 

241 - `parser` : adds a new argument with dest `<name>` (hyphens -> underscores) 

242 

243 # Usage: 

244 ```python 

245 p = argparse.ArgumentParser() 

246 add_bool_flag(p, "feature", default=False, help="enable/disable feature") 

247 ns = p.parse_args(["--feature=false"]) 

248 assert ns.feature is False 

249 ``` 

250 """ 

251 long_opt: str = f"--{name}" 

252 dest: str = name.replace("-", "_") 

253 option_strings: list[str] = [long_opt] 

254 if allow_no: 

255 option_strings.append(f"--no-{name}") 

256 

257 tokens_preview: str = "{true,false}" 

258 readable_name: str = name.replace("-", " ") 

259 arg_help: str = help or ( 

260 f"enable/disable {readable_name}; also accepts explicit true|false" 

261 ) 

262 

263 parser.add_argument( 

264 *option_strings, 

265 dest=dest, 

266 action=BoolFlagOrValue, 

267 nargs="?", 

268 default=default, 

269 metavar=tokens_preview, 

270 help=arg_help, 

271 true_set=true_set, 

272 false_set=false_set, 

273 allow_no=allow_no, 

274 allow_bare=allow_bare, 

275 )