Coverage for klayout_pex/util/argparse_helpers.py: 98%

109 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-06 13:14 +0000

1#! /usr/bin/env python3 

2# 

3# -------------------------------------------------------------------------------- 

4# SPDX-FileCopyrightText: 2024-2025 Martin Jan Köhler and Harald Pretl 

5# Johannes Kepler University, Institute for Integrated Circuits. 

6# 

7# This file is part of KPEX  

8# (see https://github.com/iic-jku/klayout-pex). 

9# 

10# This program is free software: you can redistribute it and/or modify 

11# it under the terms of the GNU General Public License as published by 

12# the Free Software Foundation, either version 3 of the License, or 

13# (at your option) any later version. 

14# 

15# This program is distributed in the hope that it will be useful, 

16# but WITHOUT ANY WARRANTY; without even the implied warranty of 

17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

18# GNU General Public License for more details. 

19# 

20# You should have received a copy of the GNU General Public License 

21# along with this program. If not, see <http://www.gnu.org/licenses/>. 

22# SPDX-License-Identifier: GPL-3.0-or-later 

23# -------------------------------------------------------------------------------- 

24# 

25 

26import argparse 

27from enum import Enum, StrEnum 

28from typing import * 

29 

30import rich.console 

31import rich.padding 

32import rich.table 

33import rich.text 

34from rich_argparse import RichHelpFormatter 

35 

36from ..log import LogLevel, warning 

37from .unit_formatter import parse_spice_number 

38 

39 

40def _group_name(name: str) -> str: 

41 """ 

42 Title-case only the group names argparse invents. 

43 

44 rich-argparse title-cases every group name, which mangles the acronyms this 

45 project uses — PEX25D would render as "Pex25D", FasterCap as "Fastercap". 

46 A title carrying an uppercase letter was written deliberately and is left 

47 alone; argparse's own all-lowercase defaults ("positional arguments", 

48 "options") still get capitalized. 

49 """ 

50 return name if any(c.isupper() for c in name) else name.title() 

51 

52 

53RichHelpFormatter.group_name_formatter = _group_name 

54 

55 

56def render_enum_help(topic: str, 

57 enum_cls: Type[Enum], 

58 print_default: bool = True, 

59 lowercase_strenum: bool = False) -> str: 

60 def canonic_string(name: str, member: str) -> str: 

61 if issubclass(enum_cls, StrEnum): 

62 if name.lower() == 'default': 

63 return 'default' 

64 return member.lower() if lowercase_strenum else member 

65 return name.lower() 

66 if not hasattr(enum_cls, 'DEFAULT'): 

67 print_default = False 

68 case_list = [f"'{canonic_string(name, member)}'" 

69 for name, member in enum_cls.__members__.items() 

70 if name.lower() != 'default'] 

71 enum_help = f"{topic} ∈ \u007b{', '.join(case_list)}\u007d" 

72 if print_default: 

73 default_case: enum_cls = getattr(enum_cls, 'DEFAULT') 

74 if issubclass(enum_cls, StrEnum): 

75 default_value: str = default_case.value 

76 if lowercase_strenum: 

77 default_value = default_value.lower() 

78 else: 

79 default_value = default_case.name.lower() 

80 enum_help += f".\nDefaults to '{default_value}'" 

81 return enum_help 

82 

83 

84def true_or_false(arg) -> bool: 

85 if isinstance(arg, bool): 

86 return arg 

87 

88 match str(arg).lower(): 

89 case 'yes' | 'true' | 't' | 'y' | 1: 

90 return True 

91 case 'no' | 'false' | 'f' | 'n' | 0: 

92 return False 

93 case _: 

94 raise argparse.ArgumentTypeError('Boolean value expected.') 

95 

96 

97class SectionedHelpFormatter(RichHelpFormatter): 

98 """ 

99 Leave the subcommands out of the option groups. 

100 

101 argparse knows a single, flat subcommand list. A parser using this formatter 

102 shows its subcommands in sections instead, see :func:`add_subcommand_sections`. 

103 The usage line still names them. 

104 """ 

105 

106 def add_argument(self, action: argparse.Action): 

107 if not isinstance(action, argparse._SubParsersAction): 

108 super().add_argument(action) 

109 

110 

111def add_subcommand_sections(parser: argparse.ArgumentParser, 

112 subparsers: argparse._SubParsersAction, 

113 sections: Mapping[str, Sequence[str]]): 

114 """ 

115 List the subcommands of ``parser`` below its description, grouped in sections. 

116 

117 Call it after all subcommands are added; their help texts come from 

118 ``add_parser(help=...)``. The parser needs :class:`SectionedHelpFormatter`. 

119 

120 :raises ValueError: unless every subcommand is in exactly one section. 

121 """ 

122 help_by_name = {action.dest: action.help for action in subparsers._choices_actions} 

123 listed = [name for names in sections.values() for name in names] 

124 if sorted(listed) != sorted(help_by_name): 

125 raise ValueError(f"Subcommand sections list {sorted(listed)}, " 

126 f"but the subcommands are {sorted(help_by_name)}") 

127 

128 name_width = max(len(name) for name in listed) 

129 renderables: List[rich.console.RenderableType] = [ 

130 rich.text.Text(str(parser.description), style='argparse.text')] 

131 for title, names in sections.items(): 

132 grid = rich.table.Table.grid(padding=(0, 2)) 

133 grid.add_column(min_width=name_width, no_wrap=True) 

134 grid.add_column() 

135 for name in names: 

136 grid.add_row(rich.text.Text(name, style='argparse.args'), 

137 rich.text.Text(help_by_name[name] or '', style='argparse.help')) 

138 renderables += [rich.text.Text(''), rich.text.Text(f'{title}:', style='argparse.groups'), 

139 rich.padding.Padding(grid, (0, 0, 0, 2))] 

140 parser.description = rich.console.Group(*renderables) 

141 

142 

143def add_help_subcommand(subparsers: argparse._SubParsersAction): 

144 """Add ``help [SUBCOMMAND]``, the same as ``-h`` on the tool or on that subcommand.""" 

145 parser = subparsers.add_parser( 

146 "help", 

147 help="Show this help, or a subcommand's help", 

148 description="Show the tool's help, or with SUBCOMMAND the same as 'SUBCOMMAND -h'.", 

149 formatter_class=RichHelpFormatter) 

150 parser.add_argument("topic", nargs='?', default=None, metavar='SUBCOMMAND', 

151 help="Subcommand to show the help of") 

152 

153 

154def handle_help_subcommand(parser: argparse.ArgumentParser, args: argparse.Namespace): 

155 """Print the help that ``help`` asked for and exit like ``-h`` does; otherwise do nothing.""" 

156 if getattr(args, 'command', None) != 'help': 

157 return 

158 if args.topic is None: 

159 parser.print_help() 

160 parser.exit() 

161 parser.parse_args([args.topic, '--help']) 

162 

163 

164def positive_spice_seconds(text: str) -> float: 

165 """argparse type: a time > 0 s as a SPICE number, e.g. '0.1p' or '1e-13'""" 

166 try: 

167 value = parse_spice_number(text) 

168 except ValueError as e: 

169 raise argparse.ArgumentTypeError(str(e)) 

170 if value <= 0: 

171 raise argparse.ArgumentTypeError(f"must be > 0 s, got '{text}'") 

172 return value 

173 

174 

175DEPRECATED_OPTIONS_DEST = 'deprecated_options' 

176"""The namespace attribute with the former option names of the command line, see warn_deprecated_options""" 

177 

178 

179class _FormerOptionName(argparse.Action): 

180 """ 

181 The former name of an option: stores the value as the option does, and notes the former name, 

182 as the warning must wait until the CLI decided where its output goes (e.g. stdout for an artifact) 

183 """ 

184 

185 def __init__(self, option_strings: List[str], dest: str, option_name: str, **kwargs): 

186 self.option_name = option_name 

187 super().__init__(option_strings, dest, **kwargs) 

188 

189 def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, 

190 values: Any, option_string: Optional[str] = None): 

191 setattr(namespace, self.dest, self.const if self.nargs == 0 else values) 

192 former_names = getattr(namespace, DEPRECATED_OPTIONS_DEST, None) or [] 

193 former_names.append((option_string, self.option_name)) 

194 setattr(namespace, DEPRECATED_OPTIONS_DEST, former_names) 

195 

196 

197def add_argument_with_former_name(container: argparse._ActionsContainer, 

198 name: str, 

199 former_name: str, 

200 *short_names: str, 

201 **kwargs) -> argparse.Action: 

202 """ 

203 Adds the option, and its former name, hidden from the help, for the scripts that still use it. 

204 The two exclude each other, and a required option is given by either name. 

205 

206 :param container: a parser or an argument group 

207 :param short_names: e.g. '-o' 

208 :param kwargs: those of add_argument, for an option that stores a value or a constant (e.g. store_true) 

209 :return: the action of the option 

210 """ 

211 names = container.add_mutually_exclusive_group(required=kwargs.pop('required', False)) 

212 action = names.add_argument(name, *short_names, **kwargs) 

213 if isinstance(action, argparse._StoreConstAction): # NOTE: also store_true and store_false 

214 value_kwargs = dict(nargs=0, const=action.const) 

215 else: 

216 value_kwargs = dict(nargs=action.nargs, type=action.type, choices=action.choices, metavar=action.metavar) 

217 names.add_argument(former_name, dest=action.dest, action=_FormerOptionName, option_name=name, 

218 default=argparse.SUPPRESS, help=argparse.SUPPRESS, **value_kwargs) 

219 return action 

220 

221 

222def warn_deprecated_options(args: argparse.Namespace): 

223 """ 

224 Warns about each former option name of the command line (see add_argument_with_former_name) 

225 """ 

226 for former_name, name in getattr(args, DEPRECATED_OPTIONS_DEST, None) or []: 

227 warning(f"{former_name} is deprecated, use {name}") 

228 

229 

230def log_level_by_name(text: str) -> LogLevel: 

231 """argparse type: a log level by its name in any case, e.g. 'debug'""" 

232 try: 

233 return LogLevel[text.upper()] 

234 except KeyError: 

235 raise argparse.ArgumentTypeError( 

236 f"'{text}' is no log level, " 

237 f"{render_enum_help(topic='log_level', enum_cls=LogLevel, print_default=False)}") 

238 

239 

240def add_log_level_argument(container: argparse._ActionsContainer): 

241 """Adds --log-level, as all tools have it""" 

242 add_argument_with_former_name(container, "--log-level", "--log_level", dest='log_level', 

243 type=log_level_by_name, default='subprocess', 

244 help=render_enum_help(topic='log_level', enum_cls=LogLevel))