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