Tested-by: Nicholas Pratte <npra...@iol.unh.edu> Reviewed-by: Nicholas Pratte <npra...@iol.unh.edu>
On Thu, May 30, 2024 at 11:25 AM Luca Vizzarro <luca.vizza...@arm.com> wrote: > > This commit introduces a new "params" module, which adds a new way > to manage command line parameters. The provided Params dataclass > is able to read the fields of its child class and produce a string > representation to supply to the command line. Any data structure > that is intended to represent command line parameters can inherit it. > > The main purpose is to make it easier to represent data structures that > map to parameters. Aiding quicker development, while minimising code > bloat. > > Signed-off-by: Luca Vizzarro <luca.vizza...@arm.com> > Reviewed-by: Paul Szczepanek <paul.szczepa...@arm.com> > --- > dts/framework/params/__init__.py | 274 +++++++++++++++++++++++++++++++ > 1 file changed, 274 insertions(+) > create mode 100644 dts/framework/params/__init__.py > > diff --git a/dts/framework/params/__init__.py > b/dts/framework/params/__init__.py > new file mode 100644 > index 0000000000..18fedcf1ff > --- /dev/null > +++ b/dts/framework/params/__init__.py > @@ -0,0 +1,274 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2024 Arm Limited > + > +"""Parameter manipulation module. > + > +This module provides :class:`Params` which can be used to model any data > structure > +that is meant to represent any command parameters. > +""" > + > +from dataclasses import dataclass, fields > +from enum import Flag > +from typing import Any, Callable, Iterable, Literal, Reversible, TypedDict, > cast > + > +from typing_extensions import Self > + > +#: Type for a function taking one argument. > +FnPtr = Callable[[Any], Any] > +#: Type for a switch parameter. > +Switch = Literal[True, None] > +#: Type for a yes/no switch parameter. > +YesNoSwitch = Literal[True, False, None] > + > + > +def _reduce_functions(funcs: Reversible[FnPtr]) -> FnPtr: > + """Reduces an iterable of :attr:`FnPtr` from end to start to a composite > function. > + > + If the iterable is empty, the created function just returns its fed > value back. > + """ > + > + def composite_function(value: Any): > + for fn in reversed(funcs): > + value = fn(value) > + return value > + > + return composite_function > + > + > +def convert_str(*funcs: FnPtr): > + """Decorator that makes the ``__str__`` method a composite function > created from its arguments. > + > + The :attr:`FnPtr`s fed to the decorator are executed from right to left > + in the arguments list order. > + > + Example: > + .. code:: python > + > + @convert_str(hex_from_flag_value) > + class BitMask(enum.Flag): > + A = auto() > + B = auto() > + > + will allow ``BitMask`` to render as a hexadecimal value. > + """ > + > + def _class_decorator(original_class): > + original_class.__str__ = _reduce_functions(funcs) > + return original_class > + > + return _class_decorator > + > + > +def comma_separated(values: Iterable[Any]) -> str: > + """Converts an iterable into a comma-separated string.""" > + return ",".join([str(value).strip() for value in values if value is not > None]) > + > + > +def bracketed(value: str) -> str: > + """Adds round brackets to the input.""" > + return f"({value})" > + > + > +def str_from_flag_value(flag: Flag) -> str: > + """Returns the value from a :class:`enum.Flag` as a string.""" > + return str(flag.value) > + > + > +def hex_from_flag_value(flag: Flag) -> str: > + """Returns the value from a :class:`enum.Flag` converted to > hexadecimal.""" > + return hex(flag.value) > + > + > +class ParamsModifier(TypedDict, total=False): > + """Params modifiers dict compatible with the :func:`dataclasses.field` > metadata parameter.""" > + > + #: > + Params_value_only: bool > + #: > + Params_short: str > + #: > + Params_long: str > + #: > + Params_multiple: bool > + #: > + Params_convert_value: Reversible[FnPtr] > + > + > +@dataclass > +class Params: > + """Dataclass that renders its fields into command line arguments. > + > + The parameter name is taken from the field name by default. The > following: > + > + .. code:: python > + > + name: str | None = "value" > + > + is rendered as ``--name=value``. > + Through :func:`dataclasses.field` the resulting parameter can be > manipulated by applying > + this class' metadata modifier functions. > + > + To use fields as switches, set the value to ``True`` to render them. If > you > + use a yes/no switch you can also set ``False`` which would render a > switch > + prefixed with ``--no-``. Examples: > + > + .. code:: python > + > + interactive: Switch = True # renders --interactive > + numa: YesNoSwitch = False # renders --no-numa > + > + Setting ``None`` will prevent it from being rendered. The > :attr:`~Switch` type alias is provided > + for regular switches, whereas :attr:`~YesNoSwitch` is offered for yes/no > ones. > + > + An instance of a dataclass inheriting ``Params`` can also be assigned to > an attribute, > + this helps with grouping parameters together. > + The attribute holding the dataclass will be ignored and the latter will > just be rendered as > + expected. > + """ > + > + _suffix = "" > + """Holder of the plain text value of Params when called directly. A > suffix for child classes.""" > + > + """========= BEGIN FIELD METADATA MODIFIER FUNCTIONS ========""" > + > + @staticmethod > + def value_only() -> ParamsModifier: > + """Injects the value of the attribute as-is without flag. > + > + Metadata modifier for :func:`dataclasses.field`. > + """ > + return ParamsModifier(Params_value_only=True) > + > + @staticmethod > + def short(name: str) -> ParamsModifier: > + """Overrides any parameter name with the given short option. > + > + Metadata modifier for :func:`dataclasses.field`. > + > + Example: > + .. code:: python > + > + logical_cores: str | None = field(default="1-4", > metadata=Params.short("l")) > + > + will render as ``-l=1-4`` instead of ``--logical-cores=1-4``. > + """ > + return ParamsModifier(Params_short=name) > + > + @staticmethod > + def long(name: str) -> ParamsModifier: > + """Overrides the inferred parameter name to the specified one. > + > + Metadata modifier for :func:`dataclasses.field`. > + > + Example: > + .. code:: python > + > + x_name: str | None = field(default="y", > metadata=Params.long("x")) > + > + will render as ``--x=y``, but the field is accessed and modified > through ``x_name``. > + """ > + return ParamsModifier(Params_long=name) > + > + @staticmethod > + def multiple() -> ParamsModifier: > + """Specifies that this parameter is set multiple times. Must be a > list. > + > + Metadata modifier for :func:`dataclasses.field`. > + > + Example: > + .. code:: python > + > + ports: list[int] | None = field( > + default_factory=lambda: [0, 1, 2], > + metadata=Params.multiple() | Params.long("port") > + ) > + > + will render as ``--port=0 --port=1 --port=2``. Note that modifiers > can be chained like > + in this example. > + """ > + return ParamsModifier(Params_multiple=True) > + > + @classmethod > + def convert_value(cls, *funcs: FnPtr) -> ParamsModifier: > + """Takes in a variable number of functions to convert the value text > representation. > + > + Metadata modifier for :func:`dataclasses.field`. > + > + The ``metadata`` keyword argument can be used to chain metadata > modifiers together. > + > + Functions can be chained together, executed from right to left in > the arguments list order. > + > + Example: > + .. code:: python > + > + hex_bitmask: int | None = field( > + default=0b1101, > + metadata=Params.convert_value(hex) | Params.long("mask") > + ) > + > + will render as ``--mask=0xd``. > + """ > + return ParamsModifier(Params_convert_value=funcs) > + > + """========= END FIELD METADATA MODIFIER FUNCTIONS ========""" > + > + def append_str(self, text: str) -> None: > + """Appends a string at the end of the string representation.""" > + self._suffix += text > + > + def __iadd__(self, text: str) -> Self: > + """Appends a string at the end of the string representation.""" > + self.append_str(text) > + return self > + > + @classmethod > + def from_str(cls, text: str) -> Self: > + """Creates a plain Params object from a string.""" > + obj = cls() > + obj.append_str(text) > + return obj > + > + @staticmethod > + def _make_switch( > + name: str, is_short: bool = False, is_no: bool = False, value: str | > None = None > + ) -> str: > + prefix = f"{'-' if is_short else '--'}{'no-' if is_no else ''}" > + name = name.replace("_", "-") > + value = f"{' ' if is_short else '='}{value}" if value else "" > + return f"{prefix}{name}{value}" > + > + def __str__(self) -> str: > + """Returns a string of command-line-ready arguments from the class > fields.""" > + arguments: list[str] = [] > + > + for field in fields(self): > + value = getattr(self, field.name) > + modifiers = cast(ParamsModifier, field.metadata) > + > + if value is None: > + continue > + > + value_only = modifiers.get("Params_value_only", False) > + if isinstance(value, Params) or value_only: > + arguments.append(str(value)) > + continue > + > + # take the short modifier, or the long modifier, or infer from > field name > + switch_name = modifiers.get("Params_short", > modifiers.get("Params_long", field.name)) > + is_short = "Params_short" in modifiers > + > + if isinstance(value, bool): > + arguments.append(self._make_switch(switch_name, is_short, > is_no=(not value))) > + continue > + > + convert = > _reduce_functions(modifiers.get("Params_convert_value", [])) > + multiple = modifiers.get("Params_multiple", False) > + > + values = value if multiple else [value] > + for value in values: > + arguments.append(self._make_switch(switch_name, is_short, > value=convert(value))) > + > + if self._suffix: > + arguments.append(self._suffix) > + > + return " ".join(arguments) > -- > 2.34.1 >