1# SPDX-License-Identifier: BSD-3-Clause 2# Copyright(c) 2024 Arm Limited 3 4"""Parameter manipulation module. 5 6This module provides :class:`Params` which can be used to model any data structure 7that is meant to represent any command line parameters. 8""" 9 10from dataclasses import dataclass, fields 11from enum import Flag 12from typing import ( 13 Any, 14 Callable, 15 Iterable, 16 Literal, 17 Reversible, 18 TypedDict, 19 TypeVar, 20 cast, 21) 22 23from typing_extensions import Self 24 25T = TypeVar("T") 26 27#: Type for a function taking one argument. 28FnPtr = Callable[[Any], Any] 29#: Type for a switch parameter. 30Switch = Literal[True, None] 31#: Type for a yes/no switch parameter. 32YesNoSwitch = Literal[True, False, None] 33 34 35def _reduce_functions(funcs: Iterable[FnPtr]) -> FnPtr: 36 """Reduces an iterable of :attr:`FnPtr` from left to right to a single function. 37 38 If the iterable is empty, the created function just returns its fed value back. 39 40 Args: 41 funcs: An iterable containing the functions to be chained from left to right. 42 43 Returns: 44 FnPtr: A function that calls the given functions from left to right. 45 """ 46 47 def reduced_fn(value): 48 for fn in funcs: 49 value = fn(value) 50 return value 51 52 return reduced_fn 53 54 55def modify_str(*funcs: FnPtr) -> Callable[[T], T]: 56 r"""Class decorator modifying the ``__str__`` method with a function created from its arguments. 57 58 The :attr:`FnPtr`\s fed to the decorator are executed from left to right in the arguments list 59 order. 60 61 Args: 62 *funcs: The functions to chain from left to right. 63 64 Returns: 65 The decorator. 66 67 Example: 68 .. code:: python 69 70 @convert_str(hex_from_flag_value) 71 class BitMask(enum.Flag): 72 A = auto() 73 B = auto() 74 75 will allow ``BitMask`` to render as a hexadecimal value. 76 """ 77 78 def _class_decorator(original_class): 79 original_class.__str__ = _reduce_functions(funcs) 80 return original_class 81 82 return _class_decorator 83 84 85def comma_separated(values: Iterable[Any]) -> str: 86 """Converts an iterable into a comma-separated string. 87 88 Args: 89 values: An iterable of objects. 90 91 Returns: 92 A comma-separated list of stringified values. 93 """ 94 return ",".join([str(value).strip() for value in values if value is not None]) 95 96 97def bracketed(value: str) -> str: 98 """Adds round brackets to the input. 99 100 Args: 101 value: Any string. 102 103 Returns: 104 A string surrounded by round brackets. 105 """ 106 return f"({value})" 107 108 109def str_from_flag_value(flag: Flag) -> str: 110 """Returns the value from a :class:`enum.Flag` as a string. 111 112 Args: 113 flag: An instance of :class:`Flag`. 114 115 Returns: 116 The stringified value of the given flag. 117 """ 118 return str(flag.value) 119 120 121def hex_from_flag_value(flag: Flag) -> str: 122 """Returns the value from a :class:`enum.Flag` converted to hexadecimal. 123 124 Args: 125 flag: An instance of :class:`Flag`. 126 127 Returns: 128 The value of the given flag in hexadecimal representation. 129 """ 130 return hex(flag.value) 131 132 133class ParamsModifier(TypedDict, total=False): 134 """Params modifiers dict compatible with the :func:`dataclasses.field` metadata parameter.""" 135 136 #: 137 Params_short: str 138 #: 139 Params_long: str 140 #: 141 Params_multiple: bool 142 #: 143 Params_convert_value: Reversible[FnPtr] 144 145 146@dataclass 147class Params: 148 """Dataclass that renders its fields into command line arguments. 149 150 The parameter name is taken from the field name by default. The following: 151 152 .. code:: python 153 154 name: str | None = "value" 155 156 is rendered as ``--name=value``. 157 158 Through :func:`dataclasses.field` the resulting parameter can be manipulated by applying 159 this class' metadata modifier functions. These return regular dictionaries which can be combined 160 together using the pipe (OR) operator, as used in the example for :meth:`~Params.multiple`. 161 162 To use fields as switches, set the value to ``True`` to render them. If you 163 use a yes/no switch you can also set ``False`` which would render a switch 164 prefixed with ``--no-``. Examples: 165 166 .. code:: python 167 168 interactive: Switch = True # renders --interactive 169 numa: YesNoSwitch = False # renders --no-numa 170 171 Setting ``None`` will prevent it from being rendered. The :attr:`~Switch` type alias is provided 172 for regular switches, whereas :attr:`~YesNoSwitch` is offered for yes/no ones. 173 174 An instance of a dataclass inheriting ``Params`` can also be assigned to an attribute, 175 this helps with grouping parameters together. 176 The attribute holding the dataclass will be ignored and the latter will just be rendered as 177 expected. 178 """ 179 180 _suffix = "" 181 """Holder of the plain text value of Params when called directly. A suffix for child classes.""" 182 183 """========= BEGIN FIELD METADATA MODIFIER FUNCTIONS ========""" 184 185 @staticmethod 186 def short(name: str) -> ParamsModifier: 187 """Overrides any parameter name with the given short option. 188 189 Args: 190 name: The short parameter name. 191 192 Returns: 193 ParamsModifier: A dictionary for the `dataclasses.field` metadata argument containing 194 the parameter short name modifier. 195 196 Example: 197 .. code:: python 198 199 logical_cores: str | None = field(default="1-4", metadata=Params.short("l")) 200 201 will render as ``-l=1-4`` instead of ``--logical-cores=1-4``. 202 """ 203 return ParamsModifier(Params_short=name) 204 205 @staticmethod 206 def long(name: str) -> ParamsModifier: 207 """Overrides the inferred parameter name to the specified one. 208 209 Args: 210 name: The long parameter name. 211 212 Returns: 213 ParamsModifier: A dictionary for the `dataclasses.field` metadata argument containing 214 the parameter long name modifier. 215 216 Example: 217 .. code:: python 218 219 x_name: str | None = field(default="y", metadata=Params.long("x")) 220 221 will render as ``--x=y``, but the field is accessed and modified through ``x_name``. 222 """ 223 return ParamsModifier(Params_long=name) 224 225 @staticmethod 226 def multiple() -> ParamsModifier: 227 """Specifies that this parameter is set multiple times. The parameter type must be a list. 228 229 Returns: 230 ParamsModifier: A dictionary for the `dataclasses.field` metadata argument containing 231 the multiple parameters modifier. 232 233 Example: 234 .. code:: python 235 236 ports: list[int] | None = field( 237 default_factory=lambda: [0, 1, 2], 238 metadata=Params.multiple() | Params.long("port") 239 ) 240 241 will render as ``--port=0 --port=1 --port=2``. 242 """ 243 return ParamsModifier(Params_multiple=True) 244 245 @staticmethod 246 def convert_value(*funcs: FnPtr) -> ParamsModifier: 247 """Takes in a variable number of functions to convert the value text representation. 248 249 Functions can be chained together, executed from left to right in the arguments list order. 250 251 Args: 252 *funcs: The functions to chain from left to right. 253 254 Returns: 255 ParamsModifier: A dictionary for the `dataclasses.field` metadata argument containing 256 the convert value modifier. 257 258 Example: 259 .. code:: python 260 261 hex_bitmask: int | None = field( 262 default=0b1101, 263 metadata=Params.convert_value(hex) | Params.long("mask") 264 ) 265 266 will render as ``--mask=0xd``. 267 """ 268 return ParamsModifier(Params_convert_value=funcs) 269 270 """========= END FIELD METADATA MODIFIER FUNCTIONS ========""" 271 272 def append_str(self, text: str) -> None: 273 """Appends a string at the end of the string representation. 274 275 Args: 276 text: Any text to append at the end of the parameters string representation. 277 """ 278 self._suffix += text 279 280 def __iadd__(self, text: str) -> Self: 281 """Appends a string at the end of the string representation. 282 283 Args: 284 text: Any text to append at the end of the parameters string representation. 285 286 Returns: 287 The given instance back. 288 """ 289 self.append_str(text) 290 return self 291 292 @classmethod 293 def from_str(cls, text: str) -> Self: 294 """Creates a plain Params object from a string. 295 296 Args: 297 text: The string parameters. 298 299 Returns: 300 A new plain instance of :class:`Params`. 301 """ 302 obj = cls() 303 obj.append_str(text) 304 return obj 305 306 @staticmethod 307 def _make_switch( 308 name: str, is_short: bool = False, is_no: bool = False, value: str | None = None 309 ) -> str: 310 """Make the string representation of the parameter. 311 312 Args: 313 name: The name of the parameters. 314 is_short: If the parameters is short or not. 315 is_no: If the parameter is negated or not. 316 value: The value of the parameter. 317 318 Returns: 319 The complete command line parameter. 320 """ 321 prefix = f"{'-' if is_short else '--'}{'no-' if is_no else ''}" 322 name = name.replace("_", "-") 323 value = f"{' ' if is_short else '='}{value}" if value else "" 324 return f"{prefix}{name}{value}" 325 326 def __str__(self) -> str: 327 """Returns a string of command-line-ready arguments from the class fields.""" 328 arguments: list[str] = [] 329 330 for field in fields(self): 331 value = getattr(self, field.name) 332 modifiers = cast(ParamsModifier, field.metadata) 333 334 if value is None: 335 continue 336 337 if isinstance(value, Params): 338 arguments.append(str(value)) 339 continue 340 341 # take the short modifier, or the long modifier, or infer from field name 342 switch_name = modifiers.get("Params_short", modifiers.get("Params_long", field.name)) 343 is_short = "Params_short" in modifiers 344 345 if isinstance(value, bool): 346 arguments.append(self._make_switch(switch_name, is_short, is_no=(not value))) 347 continue 348 349 convert = _reduce_functions(modifiers.get("Params_convert_value", [])) 350 multiple = modifiers.get("Params_multiple", False) 351 352 values = value if multiple else [value] 353 for value in values: 354 arguments.append(self._make_switch(switch_name, is_short, value=convert(value))) 355 356 if self._suffix: 357 arguments.append(self._suffix) 358 359 return " ".join(arguments) 360