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
« 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#
26import argparse
27from enum import Enum, StrEnum
28from typing import *
30import rich.console
31import rich.padding
32import rich.table
33import rich.text
34from rich_argparse import RichHelpFormatter
36from ..log import LogLevel, warning
37from .unit_formatter import parse_spice_number
40def _group_name(name: str) -> str:
41 """
42 Title-case only the group names argparse invents.
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()
53RichHelpFormatter.group_name_formatter = _group_name
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
84def true_or_false(arg) -> bool:
85 if isinstance(arg, bool):
86 return arg
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.')
97class SectionedHelpFormatter(RichHelpFormatter):
98 """
99 Leave the subcommands out of the option groups.
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 """
106 def add_argument(self, action: argparse.Action):
107 if not isinstance(action, argparse._SubParsersAction):
108 super().add_argument(action)
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.
117 Call it after all subcommands are added; their help texts come from
118 ``add_parser(help=...)``. The parser needs :class:`SectionedHelpFormatter`.
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)}")
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)
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")
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'])
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
175DEPRECATED_OPTIONS_DEST = 'deprecated_options'
176"""The namespace attribute with the former option names of the command line, see warn_deprecated_options"""
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 """
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)
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)
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.
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
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}")
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)}")
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))