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
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
1from __future__ import annotations
3import argparse
4import sys
5from collections.abc import Iterable, Sequence
6from typing import Any, Callable, Final, TypeVar
8if sys.version_info >= (3, 12):
9 from typing import override
10else:
11 from typing_extensions import override
13T_callable = TypeVar("T_callable", bound=Callable[..., Any])
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"""
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
28 return decorator
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"}
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}
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.
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.
60 # Returns:
61 - `bool`
62 parsed boolean
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}")
78class BoolFlagOrValue(argparse.Action):
79 """summary
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
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.
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
111 # Raises:
112 - `ValueError` : if nargs is not '?' or if type= is provided
113 """
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.")
129 if nargs not in (None, "?"):
130 raise ValueError("BoolFlagOrValue requires nargs='?'")
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
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)
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
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
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]
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)
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
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
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)
236 # Returns:
237 - `None`
238 nothing; parser is modified
240 # Modifies:
241 - `parser` : adds a new argument with dest `<name>` (hyphens -> underscores)
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}")
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 )
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 )