xref: /dpdk/dts/framework/settings.py (revision 7917b0d38e92e8b9ec5a870415b791420e10f11a)
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright(c) 2010-2021 Intel Corporation
3# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
4# Copyright(c) 2022 University of New Hampshire
5# Copyright(c) 2024 Arm Limited
6
7"""Environment variables and command line arguments parsing.
8
9This is a simple module utilizing the built-in argparse module to parse command line arguments,
10augment them with values from environment variables and make them available across the framework.
11
12The command line value takes precedence, followed by the environment variable value,
13followed by the default value defined in this module.
14
15The command line arguments along with the supported environment variables are:
16
17.. option:: --config-file
18.. envvar:: DTS_CFG_FILE
19
20    The path to the YAML test run configuration file.
21
22.. option:: --output-dir, --output
23.. envvar:: DTS_OUTPUT_DIR
24
25    The directory where DTS logs and results are saved.
26
27.. option:: --compile-timeout
28.. envvar:: DTS_COMPILE_TIMEOUT
29
30    The timeout for compiling DPDK.
31
32.. option:: -t, --timeout
33.. envvar:: DTS_TIMEOUT
34
35    The timeout for all DTS operation except for compiling DPDK.
36
37.. option:: -v, --verbose
38.. envvar:: DTS_VERBOSE
39
40    Set to any value to enable logging everything to the console.
41
42.. option:: -s, --skip-setup
43.. envvar:: DTS_SKIP_SETUP
44
45    Set to any value to skip building DPDK.
46
47.. option:: --tarball, --snapshot
48.. envvar:: DTS_DPDK_TARBALL
49
50    Path to DPDK source code tarball to test.
51
52.. option:: --revision, --rev, --git-ref
53.. envvar:: DTS_DPDK_REVISION_ID
54
55    Git revision ID to test. Could be commit, tag, tree ID etc.
56    To test local changes, first commit them, then use their commit ID.
57
58.. option:: --test-suite
59.. envvar:: DTS_TEST_SUITES
60
61        A test suite with test cases which may be specified multiple times.
62        In the environment variable, the suites are joined with a comma.
63
64.. option:: --re-run, --re_run
65.. envvar:: DTS_RERUN
66
67    Re-run each test case this many times in case of a failure.
68
69The module provides one key module-level variable:
70
71Attributes:
72    SETTINGS: The module level variable storing framework-wide DTS settings.
73
74Typical usage example::
75
76  from framework.settings import SETTINGS
77  foo = SETTINGS.foo
78"""
79
80import argparse
81import os
82import sys
83from argparse import Action, ArgumentDefaultsHelpFormatter, _get_action_name
84from dataclasses import dataclass, field
85from pathlib import Path
86from typing import Callable
87
88from .config import TestSuiteConfig
89from .exception import ConfigurationError
90from .utils import DPDKGitTarball, get_commit_id
91
92
93@dataclass(slots=True)
94class Settings:
95    """Default framework-wide user settings.
96
97    The defaults may be modified at the start of the run.
98    """
99
100    #:
101    config_file_path: Path = Path(__file__).parent.parent.joinpath("conf.yaml")
102    #:
103    output_dir: str = "output"
104    #:
105    timeout: float = 15
106    #:
107    verbose: bool = False
108    #:
109    skip_setup: bool = False
110    #:
111    dpdk_tarball_path: Path | str = ""
112    #:
113    compile_timeout: float = 1200
114    #:
115    test_suites: list[TestSuiteConfig] = field(default_factory=list)
116    #:
117    re_run: int = 0
118
119
120SETTINGS: Settings = Settings()
121
122
123#: Attribute name representing the env variable name to augment :class:`~argparse.Action` with.
124_ENV_VAR_NAME_ATTR = "env_var_name"
125#: Attribute name representing the value origin to augment :class:`~argparse.Action` with.
126_IS_FROM_ENV_ATTR = "is_from_env"
127
128#: The prefix to be added to all of the environment variables.
129_ENV_PREFIX = "DTS_"
130
131
132def _make_env_var_name(action: Action, env_var_name: str | None) -> str:
133    """Make and assign an environment variable name to the given action."""
134    env_var_name = f"{_ENV_PREFIX}{env_var_name or action.dest.upper()}"
135    setattr(action, _ENV_VAR_NAME_ATTR, env_var_name)
136    return env_var_name
137
138
139def _get_env_var_name(action: Action) -> str | None:
140    """Get the environment variable name of the given action."""
141    return getattr(action, _ENV_VAR_NAME_ATTR, None)
142
143
144def _set_is_from_env(action: Action) -> None:
145    """Make the environment the given action's value origin."""
146    setattr(action, _IS_FROM_ENV_ATTR, True)
147
148
149def _is_from_env(action: Action) -> bool:
150    """Check if the given action's value originated from the environment."""
151    return getattr(action, _IS_FROM_ENV_ATTR, False)
152
153
154def _is_action_in_args(action: Action) -> bool:
155    """Check if the action is invoked in the command line arguments."""
156    for option in action.option_strings:
157        if option in sys.argv:
158            return True
159    return False
160
161
162def _add_env_var_to_action(
163    action: Action,
164    env_var_name: str | None = None,
165) -> None:
166    """Add an argument with an environment variable to the parser."""
167    env_var_name = _make_env_var_name(action, env_var_name)
168
169    if not _is_action_in_args(action):
170        env_var_value = os.environ.get(env_var_name)
171        if env_var_value is not None:
172            _set_is_from_env(action)
173            sys.argv[1:0] = [action.format_usage(), env_var_value]
174
175
176class _DTSArgumentParser(argparse.ArgumentParser):
177    """ArgumentParser with a custom error message.
178
179    This custom version of ArgumentParser changes the error message to accurately reflect the origin
180    of the value of its arguments. If it was supplied through the command line nothing changes, but
181    if it was supplied as an environment variable this is correctly communicated.
182    """
183
184    def find_action(
185        self, action_dest: str, filter_fn: Callable[[Action], bool] | None = None
186    ) -> Action | None:
187        """Find and return an action by its destination variable name.
188
189        Arguments:
190            action_dest: the destination variable name of the action to find.
191            filter_fn: if an action is found it is passed to this filter function, which must
192                return a boolean value.
193        """
194        it = (action for action in self._actions if action.dest == action_dest)
195        action = next(it, None)
196
197        if action and filter_fn:
198            return action if filter_fn(action) else None
199
200        return action
201
202    def error(self, message):
203        """Augments :meth:`~argparse.ArgumentParser.error` with environment variable awareness."""
204        for action in self._actions:
205            if _is_from_env(action):
206                action_name = _get_action_name(action)
207                env_var_name = _get_env_var_name(action)
208                env_var_value = os.environ.get(env_var_name)
209
210                message = message.replace(
211                    f"argument {action_name}",
212                    f"environment variable {env_var_name} (value: {env_var_value})",
213                )
214
215        print(f"{self.prog}: error: {message}\n", file=sys.stderr)
216        self.exit(2, "For help and usage, " "run the command with the --help flag.\n")
217
218
219class _EnvVarHelpFormatter(ArgumentDefaultsHelpFormatter):
220    """Custom formatter to add environment variables to the help page."""
221
222    def _get_help_string(self, action):
223        """Overrides :meth:`ArgumentDefaultsHelpFormatter._get_help_string`."""
224        help = super()._get_help_string(action)
225
226        env_var_name = _get_env_var_name(action)
227        if env_var_name is not None:
228            help = f"[{env_var_name}] {help}"
229
230            env_var_value = os.environ.get(env_var_name)
231            if env_var_value is not None:
232                help = f"{help} (env value: {env_var_value})"
233
234        return help
235
236
237def _parse_tarball_path(file_path: str) -> Path:
238    """Validate whether `file_path` is valid and return a Path object."""
239    path = Path(file_path)
240    if not path.exists() or not path.is_file():
241        raise argparse.ArgumentTypeError("The file path provided is not a valid file")
242    return path
243
244
245def _parse_revision_id(rev_id: str) -> str:
246    """Validate revision ID and retrieve corresponding commit ID."""
247    try:
248        return get_commit_id(rev_id)
249    except ConfigurationError:
250        raise argparse.ArgumentTypeError("The Git revision ID supplied is invalid or ambiguous")
251
252
253def _get_parser() -> _DTSArgumentParser:
254    """Create the argument parser for DTS.
255
256    Command line options take precedence over environment variables, which in turn take precedence
257    over default values.
258
259    Returns:
260        _DTSArgumentParser: The configured argument parser with defined options.
261    """
262    parser = _DTSArgumentParser(
263        description="Run DPDK test suites. All options may be specified with the environment "
264        "variables provided in brackets. Command line arguments have higher priority.",
265        formatter_class=_EnvVarHelpFormatter,
266        allow_abbrev=False,
267    )
268
269    action = parser.add_argument(
270        "--config-file",
271        default=SETTINGS.config_file_path,
272        type=Path,
273        help="The configuration file that describes the test cases, SUTs and targets.",
274        metavar="FILE_PATH",
275        dest="config_file_path",
276    )
277    _add_env_var_to_action(action, "CFG_FILE")
278
279    action = parser.add_argument(
280        "--output-dir",
281        "--output",
282        default=SETTINGS.output_dir,
283        help="Output directory where DTS logs and results are saved.",
284        metavar="DIR_PATH",
285    )
286    _add_env_var_to_action(action)
287
288    action = parser.add_argument(
289        "-t",
290        "--timeout",
291        default=SETTINGS.timeout,
292        type=float,
293        help="The default timeout for all DTS operations except for compiling DPDK.",
294        metavar="SECONDS",
295    )
296    _add_env_var_to_action(action)
297
298    action = parser.add_argument(
299        "-v",
300        "--verbose",
301        action="store_true",
302        default=SETTINGS.verbose,
303        help="Specify to enable verbose output, logging all messages to the console.",
304    )
305    _add_env_var_to_action(action)
306
307    action = parser.add_argument(
308        "-s",
309        "--skip-setup",
310        action="store_true",
311        default=SETTINGS.skip_setup,
312        help="Specify to skip all setup steps on SUT and TG nodes.",
313    )
314    _add_env_var_to_action(action)
315
316    dpdk_source = parser.add_mutually_exclusive_group(required=True)
317
318    action = dpdk_source.add_argument(
319        "--tarball",
320        "--snapshot",
321        type=_parse_tarball_path,
322        help="Path to DPDK source code tarball to test.",
323        metavar="FILE_PATH",
324        dest="dpdk_tarball_path",
325    )
326    _add_env_var_to_action(action, "DPDK_TARBALL")
327
328    action = dpdk_source.add_argument(
329        "--revision",
330        "--rev",
331        "--git-ref",
332        type=_parse_revision_id,
333        help="Git revision ID to test. Could be commit, tag, tree ID etc. "
334        "To test local changes, first commit them, then use their commit ID.",
335        metavar="ID",
336        dest="dpdk_revision_id",
337    )
338    _add_env_var_to_action(action)
339
340    action = parser.add_argument(
341        "--compile-timeout",
342        default=SETTINGS.compile_timeout,
343        type=float,
344        help="The timeout for compiling DPDK.",
345        metavar="SECONDS",
346    )
347    _add_env_var_to_action(action)
348
349    action = parser.add_argument(
350        "--test-suite",
351        action="append",
352        nargs="+",
353        metavar=("TEST_SUITE", "TEST_CASES"),
354        default=SETTINGS.test_suites,
355        help="A list containing a test suite with test cases. "
356        "The first parameter is the test suite name, and the rest are test case names, "
357        "which are optional. May be specified multiple times. To specify multiple test suites in "
358        "the environment variable, join the lists with a comma. "
359        "Examples: "
360        "--test-suite suite case case --test-suite suite case ... | "
361        "DTS_TEST_SUITES='suite case case, suite case, ...' | "
362        "--test-suite suite --test-suite suite case ... | "
363        "DTS_TEST_SUITES='suite, suite case, ...'",
364        dest="test_suites",
365    )
366    _add_env_var_to_action(action)
367
368    action = parser.add_argument(
369        "--re-run",
370        "--re_run",
371        default=SETTINGS.re_run,
372        type=int,
373        help="Re-run each test case the specified number of times if a test failure occurs.",
374        metavar="N_TIMES",
375    )
376    _add_env_var_to_action(action, "RERUN")
377
378    return parser
379
380
381def _process_test_suites(
382    parser: _DTSArgumentParser, args: list[list[str]]
383) -> list[TestSuiteConfig]:
384    """Process the given argument to a list of :class:`TestSuiteConfig` to execute.
385
386    Args:
387        args: The arguments to process. The args is a string from an environment variable
388              or a list of from the user input containing tests suites with tests cases,
389              each of which is a list of [test_suite, test_case, test_case, ...].
390
391    Returns:
392        A list of test suite configurations to execute.
393    """
394    if parser.find_action("test_suites", _is_from_env):
395        # Environment variable in the form of "SUITE1 CASE1 CASE2, SUITE2 CASE1, SUITE3, ..."
396        args = [suite_with_cases.split() for suite_with_cases in args[0][0].split(",")]
397
398    return [TestSuiteConfig(test_suite, test_cases) for [test_suite, *test_cases] in args]
399
400
401def get_settings() -> Settings:
402    """Create new settings with inputs from the user.
403
404    The inputs are taken from the command line and from environment variables.
405
406    Returns:
407        The new settings object.
408    """
409    parser = _get_parser()
410
411    if len(sys.argv) == 1:
412        parser.print_help()
413        sys.exit(1)
414
415    args = parser.parse_args()
416
417    if args.dpdk_revision_id:
418        args.dpdk_tarball_path = Path(DPDKGitTarball(args.dpdk_revision_id, args.output_dir))
419
420    args.test_suites = _process_test_suites(parser, args.test_suites)
421
422    kwargs = {k: v for k, v in vars(args).items() if hasattr(SETTINGS, k)}
423    return Settings(**kwargs)
424