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..aa27e34357
--- /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 in 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