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