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
>

Reply via email to