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