diff --git a/lib/pavilion/builder.py b/lib/pavilion/builder.py index 7cdf3acef..3aebca3dc 100644 --- a/lib/pavilion/builder.py +++ b/lib/pavilion/builder.py @@ -15,7 +15,7 @@ import time import urllib.parse from pathlib import Path -from typing import Union, Dict, Optional +from typing import Union, Dict, Optional, List, IO from contextlib import ExitStack import pavilion.config @@ -27,7 +27,9 @@ from pavilion.status_file import TestStatusFile, STATES from pavilion.test_config import parse_timeout from pavilion.test_config.spack import SpackEnvConfig +from pavilion.micro import set_default, remove_none +CONFIG_FNAMES = ("suite.yaml", "hosts.yaml", "modes.yaml", "os.yaml") class TestBuilder: """Manages a test build and their organization. @@ -134,6 +136,16 @@ def __init__(self, pav_cfg: pavilion.config.PavConfig, working_dir: Path, config raise TestBuilderError("build.create_file has bad destination path '{}'" .format(dest), err) + @property + def suite_subdir(self) -> Optional[Path]: + sname = self._config.get('suite_name') + + if sname is not None: + return Path(f"suites/{sname}/") + + self.status.set(STATES.WARNING, + "Unable to determine name of test suite. Suite directory is unknown.") + def exists(self): """Return True if the given build exists.""" return self.path.exists() @@ -195,6 +207,8 @@ def _create_build_hash(self) -> str: # - All of the build's 'extra_files' # - All files needed to be created at build time 'create_files' + self.status.set(STATES.INFO, "Creating build hash.") + hash_obj = hashlib.sha256() # Update the hash with the contents of the build script. @@ -208,13 +222,17 @@ def _create_build_hash(self) -> str: if src_path is not None: if src_path.is_file(): + self.status.set(STATES.INFO, f"Hashing file {src_path}.") hash_obj.update(self._hash_file(src_path)) elif src_path.is_dir(): - hash_obj.update(self._hash_dir(src_path)) + self.status.set(STATES.INFO, f"Hashing directory {src_path}.") + hash_obj.update(self._hash_dir(src_path, exclude=CONFIG_FNAMES)) else: raise TestBuilderError( "Invalid src location {}." .format(src_path)) + else: + self.status.set(STATES.INFO, "No files to hash.") # Hash all the given template files. for tmpl_src in sorted(self._templates.keys()): @@ -223,7 +241,8 @@ def _create_build_hash(self) -> str: # Hash extra files. for extra_file in self._config.get('extra_files', []): extra_file = Path(extra_file) - full_path = self._pav_cfg.find_file(extra_file, Path('test_src')) + sub_dirs = [self.suite_subdir, Path('test_src')] + full_path = self._pav_cfg.find_file(extra_file, sub_dirs) if full_path is None: raise TestBuilderError( @@ -233,7 +252,7 @@ def _create_build_hash(self) -> str: hash_obj.update(self._hash_file(full_path)) elif full_path.is_dir(): self._date_dir(full_path) - hash_obj.update(self._hash_dir(full_path)) + hash_obj.update(self._hash_dir(full_path, exclude=CONFIG_NAMES)) else: raise TestBuilderError( "Extra file '{}' must be a regular file or directory." @@ -299,16 +318,18 @@ def deprecate(self): dep_path.touch() - def _update_src(self): + def _update_src(self) -> Optional[Path]: """Retrieve and/or check the existence of the files needed for the build. This can include pulling from URL's. :returns: src_path, extra_files """ + self.status.set(STATES.INFO, "Updating source.") + src_path = self._config.get('source_path') + if src_path is None: - # There is no source to do anything with. - return None + return try: src_path = Path(src_path) @@ -317,7 +338,8 @@ def _update_src(self): "The source path must be a valid unix path, either relative " "or absolute, got '{}'".format(src_path), err) - found_src_path = self._pav_cfg.find_file(src_path, 'test_src') + sub_dirs = [self.suite_subdir, Path('test_src')] + found_src_path = self._pav_cfg.find_file(src_path, sub_dirs) src_url = self._config.get('source_url') src_download = self._config.get('source_download') @@ -654,7 +676,7 @@ def _build(self, build_dir, cancel_event, test_id, tracker: BuildTracker) -> boo 'x-lzma', ) - def _setup_build_dir(self, dest, tracker: BuildTracker): + def _setup_build_dir(self, dest: Path, tracker: BuildTracker) -> None: """Setup the build directory, by extracting or copying the source and any extra files. :param dest: Path to the intended build directory. This is generally a @@ -663,31 +685,46 @@ def _setup_build_dir(self, dest, tracker: BuildTracker): :return: None """ + tracker.update(state=STATES.BUILDING, note="Setting up build directory.") + umask = os.umask(0) os.umask(umask) + src_path = None raw_src_path = self._config.get('source_path') - if raw_src_path is None: - src_path = None - else: - src_path = self._pav_cfg.find_file(Path(raw_src_path), 'test_src') + + if raw_src_path is not None: + tracker.update(state=STATES.BUILDING, note=f"Looking for source path: {raw_src_path}.") + sub_dirs = [Path('test_src')] + src_path = self._pav_cfg.find_file(raw_src_path, sub_dirs) + + # Only raise an error if a path that is explicitly identified is missing if src_path is None: raise TestBuilderError("Could not find source file '{}'" .format(raw_src_path)) - - # Resolve any softlinks to get the real file. - src_path = src_path.resolve() + else: + # Default to the suite directory, which may or may not exist + # If it doesn't exist, we should just continue without raising an error. + if self.suite_subdir is not None: + tracker.update(state=STATES.BUILDING, + note=f"No source path given. Defaulting to {self.suite_subdir}.") + src_path = self._pav_cfg.find_file(self.suite_subdir) umask = int(self._pav_cfg['umask'], 8) # All of the file extraction functions return an error message on failure, None on success. extract_error = None + if src_path is not None: + # Resolve any softlinks to get the real file. + src_path = src_path.resolve() + if src_path is None: + tracker.update(state=STATES.BUILDING, + note=f"No source path found. Creating empty build directory.") # If there is no source archive or data, just make the build # directory. dest.mkdir() - elif src_path.is_dir(): # Recursively copy the src directory to the build directory. tracker.update( @@ -701,7 +738,9 @@ def _setup_build_dir(self, dest, tracker: BuildTracker): dest.as_posix(), copy_function=shutil.copyfile, copystat=utils.make_umask_filtered_copystat(umask), - symlinks=True) + symlinks=True, + ignore=shutil.ignore_patterns("*.yaml") + ) elif src_path.is_file(): category, subtype = utils.get_mime_type(src_path) @@ -777,7 +816,8 @@ def _setup_build_dir(self, dest, tracker: BuildTracker): # Now we just need to copy over all the extra files. for extra in self._config.get('extra_files', []): extra = Path(extra) - path = self._pav_cfg.find_file(extra, 'test_src') + sub_dirs = [self.suite_subdir, Path('test_src')] + path = self._pav_cfg.find_file(extra, sub_dirs) final_dest = dest / path.name try: if path.is_dir(): @@ -952,7 +992,7 @@ def _hash_file(self, path, save=True): return file_hash @classmethod - def _hash_io(cls, contents): + def _hash_io(cls, contents: IO) -> bytes: """Hash the given file in IOString format. :param IOString contents: file name (as relative path to build directory) and file contents to hash.""" @@ -965,17 +1005,38 @@ def _hash_io(cls, contents): return hash_obj.digest() - @staticmethod - def _hash_dir(path): - """Instead of hashing the files within a directory, we just create a - 'hash' based on it's name and mtime, assuming we've run _date_dir - on it before hand. This produces an arbitrary string, not a hash. - :param Path path: The path to the directory. - :returns: The 'hash' + @classmethod + def _hash_dir(cls, path: Path, exclude: List[str] = None) -> str: + """Recursively hash the files in the given directory, optionally excluding files with + the given names. + + Returns the hexadecimal hash digest of all files in the directory, as a UTF-8 string. """ - dir_stat = path.stat() - return '{} {:0.5f}'.format(path, dir_stat.st_mtime).encode() + exclude = set_default(exclude, []) + + try: + # Order is indeterminate, so sort the files + files = sorted(path.rglob('*')) + except OSError: + # This will typically be caught earlier by _date_dir + raise TestBuilderError(f"Unable to hash directory: {path}. Possible circular symlink.") + + files = filter(lambda x: x.name not in exclude, files) + + hash_obj = hashlib.sha256() + + for file in files: + if file.is_dir(): + # This has the effect of flattening directories, + # thus ignoring the structure of the directory. + # This might not be what we want. + continue + + with open(file, 'rb') as fin: + hash_obj.update(cls._hash_io(fin)) + + return hash_obj.hexdigest().encode("utf-8") @staticmethod def _isurl(url): @@ -998,8 +1059,8 @@ def _date_dir(base_path): dir_stat = path.stat() except OSError as err: raise TestBuilderError( - "Could not stat file in test source dir '{}'" - .format(base_path), err) + (f"Could not stat file in test source dir '{base_path}'. " + "Possible circular symlink."), err) if dir_stat.st_mtime > latest: latest = dir_stat.st_mtime diff --git a/lib/pavilion/commands/_run.py b/lib/pavilion/commands/_run.py index f1bd1f824..597192886 100644 --- a/lib/pavilion/commands/_run.py +++ b/lib/pavilion/commands/_run.py @@ -45,7 +45,7 @@ def run(self, pav_cfg, args): try: tests.append(TestRun.load_from_raw_id(pav_cfg, test_id)) except PavilionError as err: - fprint(self.outfile, "Error loading test '{}'".format(args.test_id)) + fprint(self.outfile, "Error loading test '{}'".format(test_id)) fprint(self.outfile, err.pformat()) # Filter out cancelled tests diff --git a/lib/pavilion/commands/config.py b/lib/pavilion/commands/config.py index f3a5ceae9..1b40b2577 100644 --- a/lib/pavilion/commands/config.py +++ b/lib/pavilion/commands/config.py @@ -244,7 +244,7 @@ def create_config_dir(self, pav_cfg: config.PavConfig, path: Path, raise ConfigCmdError("Error writing config file at '{}'" .format(config_file_path), err) - for subdir in 'hosts', 'modes', 'tests', 'os', 'test_src', 'plugins', 'collections': + for subdir in ('hosts', 'modes', 'os', 'plugins', 'collections', 'suites'): subdir = path/subdir try: subdir.mkdir() diff --git a/lib/pavilion/commands/show.py b/lib/pavilion/commands/show.py index ec9526bc3..d228ec46e 100644 --- a/lib/pavilion/commands/show.py +++ b/lib/pavilion/commands/show.py @@ -492,7 +492,9 @@ def show_vars(self, pav_cfg, cfg, conf_type): """Show the variables of a config, each variable is displayed as a table.""" - _, file = resolver.TestConfigResolver(pav_cfg).find_config(conf_type, cfg) + cfg_info = resolver.TestConfigResolver(pav_cfg).find_config(conf_type, cfg) + file = cfg_info.path + if file is None: output.fprint( self.errfile, @@ -604,7 +606,9 @@ def show_configs_table(self, pav_cfg, conf_type, errors=False, def show_full_config(self, pav_cfg, cfg_name, conf_type): """Show the full config of a given os/host/mode.""" - _, file = resolver.TestConfigResolver(pav_cfg).find_config(conf_type, cfg_name) + cfg_info = resolver.TestConfigResolver(pav_cfg).find_config(conf_type, cfg_name) + file = cfg_info.path + config_data = None if file is not None: with file.open() as config_file: diff --git a/lib/pavilion/config.py b/lib/pavilion/config.py index 6b4c286d9..a7b2b1a20 100644 --- a/lib/pavilion/config.py +++ b/lib/pavilion/config.py @@ -11,11 +11,15 @@ import sys from collections import OrderedDict from pathlib import Path -from typing import List, Union, Dict, NewType +from itertools import product, starmap +from typing import List, Union, Dict, NewType, Iterator, Tuple import yaml_config as yc from pavilion import output from pavilion import errors +from pavilion.micro import first, flatten, remove_none, set_default +from pavilion.path_utils import Pathlike, append_to_path, append_suffix, exists, path_product +from pavilion.status_file import STATES # Figure out what directories we'll search for the base configuration. PAV_CONFIG_SEARCH_DIRS = [Path('./').resolve()] @@ -182,7 +186,6 @@ def as_dict(self) -> dict: return adict - class PavConfig(PavConfigDict): """Define types and attributes for Pavilion config options.""" @@ -217,35 +220,96 @@ def __init__(self, set_attrs=None): super().__init__(set_attrs) - def find_file(self, file: Path, sub_dir: Union[str, Path] = None) \ + @property + def config_paths(self) -> Iterator[Path]: + """Return an iterator of paths to all config directories""" + return (Path(cfg['path']) for cfg in self.configs.values()) + + @property + def tests_dirs(self) -> Iterator[Path]: + """Return an iterator of paths to all test directories.""" + return (path / 'tests' for path in self.config_paths) + + @property + def suites_dirs(self) -> Iterator[Path]: + """Return an iterator of paths to all suites directories""" + return (path / 'suites' for path in self.config_paths) + + @property + def suite_paths(self) -> Iterator[Path]: + """Return an iterator of paths to all test suites""" + return flatten(path.iterdir() for path in self.suites_dirs) + + @property + def suite_names(self) -> Iterator[str]: + """Return an iterator of suite names""" + + def is_suite(file: Path) -> bool: + return file.exists() and file.is_file() and file.suffix == '.yaml' + + test_files = map(is_suite, self.suite_paths) + + return map(lambda x: x.stem, self.suite_paths) + + @property + def suite_info(self) -> List[Tuple[str, str, Path]]: + """Get the label, name, and path for every suite the config + knows about.""" + + suite_infos = [] + + for label, cfg in self.configs.items(): + tests_dir = Path(cfg['path']) / 'tests' + suites_dir = Path(cfg['path']) / 'suites' + + if tests_dir.exists(): + tests = [file for file in tests_dir.iterdir() if file.suffix.lower() == ".yaml"] + names = [test.name for test in tests] + labels = [label] * len(tests) + + suite_infos.extend(zip(labels, names, tests)) + + if suites_dir.exists(): + suites = [sdir / "suite.yaml" for sdir in suites_dir.iterdir()] + suites = list(filter(exists, suites)) + names = [suite.parent.name for suite in suites] + labels = [label] * len(suites) + + suite_infos.extend(zip(labels, names, suites)) + + return suite_infos + + def find_file(self, file: Pathlike, sub_dirs: Union[List[Pathlike], Pathlike] = None) \ -> Union[Path, None]: """Look for the given file and return a full path to it. Relative paths are searched for in all config directories under 'sub_dir', if it exists. :param file: The path to the file. - :param sub_dir: The subdirectory in each config directory in which to - search. + :param sub_dirs: The subdirectory (or list of subdirectories) in which to + search in each directory. :returns: The full path to the found file, or None if no such file could be found.""" + file = Path(file) + if file.is_absolute(): if file.exists(): return file else: return None - # Assemble a potential location from each config dir. - for config in self.configs.values(): - path = config['path'] - if sub_dir is not None: - path = path/sub_dir - path = path/file + sub_dirs = set_default(sub_dirs, []) + sub_dirs = list(remove_none(list(sub_dirs))) - if path.exists(): - return path + if len(sub_dirs) > 0: + paths = list(path_product(self.config_paths, sub_dirs)) + else: + paths = list(self.config_paths) - return None + files = list(map(append_to_path(file), paths)) + # Return the first path to the file that exists (or None) + return first(exists, files) class ExPathElem(yc.PathElem): """Expand environment variables in the path.""" diff --git a/lib/pavilion/create_files.py b/lib/pavilion/create_files.py index 9b7ddb8cb..57eb02230 100644 --- a/lib/pavilion/create_files.py +++ b/lib/pavilion/create_files.py @@ -1,12 +1,14 @@ """Functions to dynamically generate test files.""" from pathlib import Path -from typing import List, Union, TextIO +from typing import List, Union, TextIO, Any import pavilion.config from pavilion import resolve from pavilion import utils from pavilion import variables +from pavilion.config import PavConfig +from pavilion.variables import VariableSetManager from pavilion.errors import TestConfigError @@ -60,16 +62,18 @@ def verify_path(dest, rel_path) -> Path: return file_path -def resolve_template(pav_cfg: pavilion.config.PavConfig, template: str, - var_man: variables.VariableSetManager) -> List[str]: - """Resolve each of the template files specified in the test config.""" +def resolve_template(pav_cfg: PavConfig, template_fname: str, var_man: VariableSetManager) -> Any: + """Resolve a single template file specified in the test config. Return a resolved + component.""" + + # TODO: This needs to be the test-specific suites directory + tmpl_path = pav_cfg.find_file(template_fname, ['suites', 'test_src']) - tmpl_path = pav_cfg.find_file(Path(template), 'test_src') if tmpl_path is None: raise TestConfigError("Template file '{}' from 'templates' does not exist in " - "any 'test_src' dir (Note that it must be in a Pavilion config " - "area's test_src directory - NOT the build directory.)" - .format(template)) + "any 'suites' dir (Note that it must be in a Pavilion config " + "area's suites directory - NOT the build directory.)" + .format(template_fname)) try: with tmpl_path.open() as tmpl_file: diff --git a/lib/pavilion/filters/__init__.py b/lib/pavilion/filters/__init__.py index 04c412ce2..a6e14399a 100644 --- a/lib/pavilion/filters/__init__.py +++ b/lib/pavilion/filters/__init__.py @@ -5,4 +5,4 @@ validate_str_list, validate_datetime) from .errors import FilterParseError from .common import identity, const -from .parse_time import parse_duration +from .parse_time import parse_duration, safe_update diff --git a/lib/pavilion/filters/parse_time.py b/lib/pavilion/filters/parse_time.py index ac76e9b06..d8a775d1d 100644 --- a/lib/pavilion/filters/parse_time.py +++ b/lib/pavilion/filters/parse_time.py @@ -1,4 +1,5 @@ from datetime import date, time, datetime, timedelta +from calendar import monthrange from typing import Tuple, Union @@ -62,12 +63,16 @@ def parse_duration(rval: str, now: datetime) -> datetime: raise ValueError(f"Invalid unit {unit} for duration") if unit == 'years': - return now.replace(year=now.year - mag) + return safe_update(now, year=now.year - mag) if unit == 'months': dyear, dmonth = divmod(mag, MONTHS_PER_YEAR) - return now.replace(year=now.year - dyear, month=now.month - dmonth) + new_day = now.day + new_month = now.month - dmonth + new_year = now.year - dyear + + return safe_update(now, year=new_year, month=new_month, day=new_day) return now - timedelta(**{unit: mag}) @@ -137,3 +142,28 @@ def normalize(unit: str) -> str: return unit + "s" return unit + + +def safe_update(date: datetime, year: int = None, month: int = None, day: int = None) -> datetime: + """Update the datetime object with the given year, month, and day, ensuring that the final + day is valid for the month and year, returning the modified datetime object. For instance, we + want to guard against February 30th. This function is necessary because datetime.timedelta + can't handle variable-size units of time (i.e. months and years). + """ + + if year is None: + year = date.year + if month is None: + month = date.month + if day is None: + day = date.day + + max_day = monthrange(year, month)[1] + + # If the day is too large, adjust it to be the last day + # of the new month. This might not be the best solution, + # but it's a reasonable way to handle it. + if day > max_day: + day = max_day + + return date.replace(year=year, month=month, day=day) diff --git a/lib/pavilion/micro.py b/lib/pavilion/micro.py new file mode 100644 index 000000000..a4b22d744 --- /dev/null +++ b/lib/pavilion/micro.py @@ -0,0 +1,81 @@ +"""A collection of 'microfunctions' primarily designed to abstract common +tasks and patterns, for the purpose of conciseness and readability.""" + +from pathlib import Path +from itertools import filterfalse, chain, tee +from typing import (List, Union, TypeVar, Iterator, Iterable, Callable, Optional, + Hashable, Dict, Tuple) + +T = TypeVar('T') +U = TypeVar('U') + + +def partition(pred: Callable[[T], bool], lst: Iterable[T]) -> Tuple[Iterator[T], Iterator[T]]: + """Partition the sequence into two sequences: one consisting of the elements + for which the given predicate is true and one consisting of those for + which it is false.""" + + f_true, f_false = tee(lst) + + return filter(pred, f_true), filterfalse(pred, f_false) + +def flatten(lst: Iterable[Iterable[T]]) -> Iterator[T]: + """Convert a singly nested iterable into an unnested iterable.""" + return chain.from_iterable(lst) + +def remove_all(lst: Iterable[T], item: T) -> Iterator[T]: + """Remove all instances of the given item from the iterable.""" + return filter(lambda x: x != item, lst) + +def unique(lst: Iterable[T]) -> List[T]: + """Return a list of the unique items in the original list.""" + return list(set(lst)) + +def replace(lst: Iterable[T], old: T, new: T) -> Iterator[T]: + """Replace all instances of old with new.""" + return map(lambda x: new if x == old else x, lst) + +def remove_none(lst: Iterable[T]) -> Iterator[T]: + """Remove all instances of None from the iterable.""" + return remove_all(lst, None) + +def first(pred: Callable[[T], bool], lst: Iterable[T]) -> Optional[T]: + """Return the first item of the list that satisfies the given + predicate, or None if no item does.""" + + for item in filter(pred, lst): + return item + +def apply_to_first(func: Callable[[T], U], pred: Callable[[T], bool], + lst: Iterable[T]) -> Optional[U]: + """Apply the function to the first element of the list that satisfies + the given predicate. If no element satisfies the predicate, return None.""" + + fst = first(pred, lst) + + if fst is not None: + return func(fst) + +def get_nested(keys: Iterable[Hashable], nested_dict: Dict) -> Dict: + """Gets the values associated with the given sequence of keys + out of a nested dictionary. If any key in the sequence does + not exist during the process, returns an empty dictionary.""" + + for key in keys: + nested_dict = nested_dict.get(key, {}) + + return nested_dict + +def listmap(func: Callable[[T], U], lst: Iterable[T]) -> List[U]: + """Map a function over an iterable, but return a list instead + of a map object.""" + return list(map(func, lst)) + +def set_default(val: Optional[T], default: T) -> T: + """Set the input value to default, if the original value is None. + Otherwise, return the value unchanged.""" + + if val is None: + return default + + return val diff --git a/lib/pavilion/path_utils.py b/lib/pavilion/path_utils.py new file mode 100644 index 000000000..0ec949bc0 --- /dev/null +++ b/lib/pavilion/path_utils.py @@ -0,0 +1,44 @@ +"""Simple utilities for dealing with paths.""" + +from pathlib import Path +from operator import truediv +from itertools import starmap, product +from typing import Union, Callable, Iterable, Iterator + +Pathlike = Union[Path, str] + +def exists(path: Path) -> bool: + """Wraps Path.exists, which obviates the need for + a lambda function when mapping it.""" + + return path.exists() + +def append_suffix(path: Path) -> Callable[[Pathlike], Path]: + """Constructs a function that takes a suffix and appends + it to a constant path. Intended for use with map.""" + + def func(suffix: Pathlike) -> Path: + return path / suffix + + return func + +def append_to_path(suffix: Pathlike) -> Callable[[Path], Path]: + """Constructs a function that takes a path and appends a + constant suffix to it. Intended for use with map.""" + + def func(path: Path) -> Path: + return path / suffix + + return func + +def shortpath(path: Path, parents: int = 1) -> Path: + """Return an abbreviated version of a path, where only + the specified number of parents are included.""" + + return Path(*path.parts[-(1 + parents):]) + +def path_product(roots: Iterable[Path], stems: Iterable[Pathlike]) -> Iterator[Path]: + """Given a list of root paths and a list of stem paths, returns an iterator + over all paths formed by the Cartesian products of those lists.""" + + return starmap(truediv, product(roots, stems)) diff --git a/lib/pavilion/resolve.py b/lib/pavilion/resolve.py index 8cba6162e..769b88498 100644 --- a/lib/pavilion/resolve.py +++ b/lib/pavilion/resolve.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Union, Tuple +from typing import Dict, List, Union, Tuple, Any from pavilion import parsers from pavilion import variables @@ -132,7 +132,7 @@ def section_values(component: Union[Dict, List, str], var_man: variables.VariableSetManager, allow_deferred: bool = False, deferred_only: bool = False, - key_parts: Union[None, Tuple[str]] = None): + key_parts: Union[None, Tuple[str]] = None) -> Any: """Recursively resolve the given config component's value strings using a variable manager. diff --git a/lib/pavilion/resolver/resolver.py b/lib/pavilion/resolver/resolver.py index cc6567f3c..b473abc78 100644 --- a/lib/pavilion/resolver/resolver.py +++ b/lib/pavilion/resolver/resolver.py @@ -18,10 +18,11 @@ import sys from collections import defaultdict from pathlib import Path -from typing import List, IO, Dict, Tuple, NewType, Union, Any, Iterator, TextIO +from typing import List, IO, Dict, Tuple, NewType, Union, Any, Iterator, TextIO, Optional, Iterable import similarity import yc_yaml +import yaml_config as yc from pavilion.enums import Verbose from pavilion import output, variables from pavilion import pavilion_variables @@ -36,7 +37,9 @@ KEY_NAME_RE) from pavilion.test_config.file_format import TestConfigLoader, TestSuiteLoader from pavilion.utils import union_dictionary -from yaml_config import RequiredError +from pavilion.micro import first, listmap +from pavilion.path_utils import append_to_path, exists +from yaml_config import RequiredError, YamlConfigLoader from .proto_test import RawProtoTest, ProtoTest from .request import TestRequest @@ -50,6 +53,19 @@ TEST_VERS_RE = re.compile(r'^\d+(\.\d+){0,2}$') +TestConfig = Dict + + +class ConfigInfo: + def __init__(self, name: str, type: str, path: Path, label: str = None, + from_suite: bool = False): + + self.name = name + self.type = type + self.label = label + self.path = path + self.from_suite = from_suite + class TestConfigResolver: """Converts raw test configurations into their final, fully resolved @@ -70,6 +86,7 @@ def __init__(self, pav_cfg, op_sys: str = None, host: str = None, self._outfile = io.StringIO() if outfile is None else outfile self._verbosity = verbosity self._loader = TestConfigLoader() + self._suite_loader = TestSuiteLoader() self.errors = [] self._base_var_man = variables.VariableSetManager() @@ -101,52 +118,132 @@ def __init__(self, pav_cfg, op_sys: str = None, host: str = None, # Raw loaded test suites self._suites: Dict[Dict] = {} - CONF_TYPE_DIRNAMES = { - 'suite': 'tests', - 'series': 'series', - 'OS': 'os', - 'host': 'hosts', - 'hosts': 'hosts', - 'mode': 'modes', - 'modes': 'modes', - } - - def find_config(self, conf_type, conf_name) -> Tuple[str, Path]: + @staticmethod + def _get_config_dirname(cfg_type: str, use_suites_dir: bool = False) -> str: + """Returns the canonical config directory name for a given config type.""" + + dirname = cfg_type.lower() + + if cfg_type == "suite" and not use_suites_dir: + return "tests" + + if dirname[-1] != 's' and dirname != "os": + dirname += 's' + + return dirname + + @staticmethod + def _get_config_fname(cfg_type: str) -> str: + """Given a config type, returns the name of the file in the + suites directory corresponding to that type.""" + + fname = cfg_type.lower() + + if fname in ("host", "mode"): + fname += 's' + + return f"{fname}.yaml" + + @property + def config_paths(self) -> Iterator[Path]: + """Return an iterator over all config paths.""" + return self.pav_cfg.config_paths + + @property + def suites_dirs(self) -> Iterator[Path]: + """Return an iterator over all suites directories.""" + return self.pav_cfg.suites_dirs + + @property + def config_labels(self) -> Iterator[str]: + """Return an iterator over all config labels.""" + return self.pav_cfg.configs.keys() + + def _get_test_config_path(self, cfg_name: str, cfg_type: str) -> Tuple[str, Optional[Path]]: + """Given a config name and type, find the path to that config, if it exists, + excluding configs in the suites directory. If no such config exists, + return None.""" + + cfg_dir = self._get_config_dirname(cfg_type) + paths = map(append_to_path(f"{cfg_dir}/{cfg_name}.yaml"), self.config_paths) + pairs = zip(self.config_labels, paths) + + res = first(lambda x: x[1].exists(), pairs) + + if res is None: + return '', None + + return res + + def _config_path_from_suite(self, suite_name: str, + conf_type: str) -> Tuple[str, Optional[Path]]: + """Given a suite name, return the path to the config file of the specified + type, if one exists. If the file does not exist in any known suites directory, + returns None.""" + + paths = [] + labels = list(self.config_labels) + + cfg_fname = self._get_config_fname(conf_type) + + if conf_type == "suite": + paths.extend(listmap(append_to_path(f"{suite_name}.yaml"), self.suites_dirs)) + labels *= 2 + + paths.extend(listmap(append_to_path(f"{suite_name}/{cfg_fname}"), self.suites_dirs)) + + pairs = zip(labels, paths) + + res = first(lambda x: x[1].exists(), pairs) + + if res is None: + return '', None + + return res + + def find_config(self, cfg_type: str, cfg_name: str, suite_name: str = None) -> ConfigInfo: """Search all of the known configuration directories for a config of the - given type and name. + given type and name, and report whether it was found in the suites directory. :param str conf_type: 'host', 'os', 'mode', or 'test/suite' :param str conf_name: The name of the config (without a file extension). - :return: A tuple of the config label under which a matching config was found - and the path to that config. If nothing was found, returns (None, None). + :return: A tuple of the path to that config, if it exists, and a boolean + indicating whether it was found in the suites directory (True) or not (False). """ - conf_type = self.CONF_TYPE_DIRNAMES[conf_type] + cfg_path = None - for label, config in self.pav_cfg.configs.items(): - path = config['path']/conf_type/'{}.yaml'.format(conf_name) - if path.exists(): - return label, path + if suite_name is not None: + label, cfg_path = self._config_path_from_suite(suite_name, cfg_type) + + if cfg_path is not None: + from_suite = True + else: + label, cfg_path = self._get_test_config_path(cfg_name, cfg_type) + from_suite = False - return '', None + return ConfigInfo(cfg_name, cfg_type, cfg_path, label, from_suite) - def find_similar_configs(self, conf_type, conf_name) -> List[str]: + def find_similar_configs(self, conf_type: str, conf_name: str) -> List[str]: """Find configs with a name similar to the one specified.""" - conf_type = self.CONF_TYPE_DIRNAMES[conf_type] + # TODO: modify this for new suites directory + # It will need to search inside suites config files to find names of modes, hosts, etc. + + conf_dir = self._get_config_dirname(conf_type) for label, config in self.pav_cfg.configs.items(): - type_path = config['path']/conf_type + type_path = config['path'] / conf_type names = [] - if type_path.exists(): + + if type_path is not None and type_path.exists(): for file in type_path.iterdir(): if file.name.endswith('.yaml') and not file.is_dir(): names.append(file.name[:-5]) return similarity.find_matches(conf_name, names) - def find_all_tests(self): """Find all the tests within known config directories. @@ -169,70 +266,57 @@ def find_all_tests(self): suites = {} - for label, config in self.pav_cfg.configs.items(): - path = config['path']/'tests' - - if not (path.exists() and path.is_dir()): - continue - - for file in os.listdir(path.as_posix()): + for label, name, path in self.pav_cfg.suite_info: + if name not in suites: + suites[name] = { + 'path': path, + 'label': label, + 'err': '', + 'tests': {}, + 'supersedes': [], + } + else: + suites[name]['supersedes'].append(path) - file = path/file - if file.suffix != '.yaml' or not file.is_file(): + # It's ok if the tests aren't completely validated. They + # may have been written to require a real host/mode file. + with path.open('r') as suite_file: + try: + suite_cfg = self._suite_loader.load(suite_file, partial=True) + except (TypeError, + KeyError, + ValueError, + yc_yaml.YAMLError) as err: + suites[name]['err'] = err continue - suite_name = file.stem - - if suite_name not in suites: - suites[suite_name] = { - 'path': file, - 'label': label, - 'err': '', - 'tests': {}, - 'supersedes': [], - } - else: - suites[suite_name]['supersedes'].append(file) + base = self._loader.load_empty() - # It's ok if the tests aren't completely validated. They - # may have been written to require a real host/mode file. - with file.open('r') as suite_file: - try: - suite_cfg = TestSuiteLoader().load(suite_file, partial=True) - except (TypeError, - KeyError, - ValueError, - yc_yaml.YAMLError) as err: - suites[suite_name]['err'] = err - continue - - base = self._loader.load_empty() - - try: - suite_cfgs = self.resolve_inheritance( - suite_cfg=suite_cfg, - suite_path=file) - except Exception as err: # pylint: disable=W0703 - suites[suite_name]['err'] = err - continue + try: + suite_cfgs = self.resolve_inheritance( + suite_cfg=suite_cfg, + suite_path=path) + except Exception as err: # pylint: disable=W0703 + suites[name]['err'] = err + continue - def default(val, dval): - """Return the dval if val is None.""" + def default(val, dval): + """Return the dval if val is None.""" - return dval if val is None else val + return dval if val is None else val - for test_name, conf in suite_cfgs.items(): - suites[suite_name]['tests'][test_name] = { - 'conf': conf, - 'maintainer': default( - conf['maintainer']['name'], ''), - 'email': default(conf['maintainer']['email'], ''), - 'summary': default(conf.get('summary', ''), ''), - 'doc': default(conf.get('doc', ''), ''), - } + for test_name, conf in suite_cfgs.items(): + suites[name]['tests'][test_name] = { + 'conf': conf, + 'maintainer': default( + conf['maintainer']['name'], ''), + 'email': default(conf['maintainer']['email'], ''), + 'summary': default(conf.get('summary', ''), ''), + 'doc': default(conf.get('doc', ''), ''), + } return suites - def find_all_configs(self, conf_type): + def find_all_configs(self, conf_type: str): """ Find all configs (host/modes) within known config directories. :return: Returns a dictionary of suite names to an info dict. @@ -250,7 +334,7 @@ def find_all_configs(self, conf_type): """ - conf_dir = self.CONF_TYPE_DIRNAMES[conf_type] + conf_dir = self._get_config_dirname(conf_type) configs = {} for config in self.pav_cfg.configs.values(): @@ -314,10 +398,6 @@ def load_iter(self, tests: List[str], modes: List[str] = None, overrides: List[s if modes is None: modes = [] - if overrides is None: - overrides = [] - - if overrides is None: overrides = [] @@ -327,6 +407,7 @@ def load_iter(self, tests: List[str], modes: List[str] = None, overrides: List[s requests = [TestRequest(req) for req in tests] raw_tests = [] + for request in requests: # Convert each request into a list of RawProtoTest objects. try: @@ -517,69 +598,73 @@ def _resolve_escapes(self, ptests: ProtoTest) -> List[ProtoTest]: return multiplied_tests - def _load_raw_config(self, name: str, config_type: str, optional=False) \ - -> Tuple[Any, Union[Path, None], Union[str, None]]: - """Load the given raw test config file. It can be a host, mode, or suite file. - Returns a tuple of the config, path, and config label (name of the config area). - """ - - if config_type in ('host', 'OS', 'mode'): - loader = self._loader - elif config_type == 'suite': - loader = TestSuiteLoader() - else: - raise RuntimeError("Unknown config type: '{}'".format(config_type)) - - cfg_label, path = self.find_config(config_type, name) - if path is None: + @staticmethod + def _safe_load_config(cfg: ConfigInfo, loader: yc.YamlConfigLoader) -> TestConfig: + """Given a path to a config, load the config, and raise an appropriate + error if it can't be loaded""" - if optional: - return None, None, None - - # Give a special message if it looks like they got their commands mixed up. - if config_type == 'suite' and name == 'log': - raise TestConfigError( - "Could not find test suite 'log'. Were you trying to run `pav log run`?") + path = cfg.path + cfg_type = cfg.type - similar = self.find_similar_configs(config_type, name) - show_type = 'test' if config_type == 'suite' else config_type - if similar: - raise TestConfigError( - "Could not find {} config {}.yaml.\n" - "Did you mean one of these? {}" - .format(config_type, name, ', '.join(similar))) - else: - raise TestConfigError( - "Could not find {0} config file '{1}.yaml' in any of the " - "Pavilion config directories.\n" - "Run `pav show {2}` to get a list of available {0} files." - .format(config_type, name, show_type)) try: with path.open() as cfg_file: raw_cfg = loader.load_raw(cfg_file) except (IOError, OSError) as err: raise TestConfigError("Could not open {} config '{}'" - .format(config_type, path), prior_error=err) + .format(cfg_type, path), prior_error=err) except ValueError as err: raise TestConfigError( "{} config '{}' has invalid value." - .format(config_type.capitalize(), path), prior_error=err) + .format(cfg_type.capitalize(), path), prior_error=err) except KeyError as err: raise TestConfigError( "{} config '{}' has an invalid key." - .format(config_type.capitalize(), path), prior_error=err) + .format(cfg_type.capitalize(), path), prior_error=err) except yc_yaml.YAMLError as err: raise TestConfigError( "{} config '{}' has a YAML Error" - .format(config_type.capitalize(), path), prior_error=err) - + .format(cfg_type.capitalize(), path), prior_error=err) except TypeError as err: raise TestConfigError( "Structural issue with {} config '{}'" - .format(config_type, path), prior_error=err) + .format(cfg_type, path), prior_error=err) + + return raw_cfg - return raw_cfg, path, cfg_label + def _load_raw_config(self, cfg_info: ConfigInfo, loader: yc.YamlConfigLoader, + optional: bool = False) -> Optional[TestConfig]: + """Given a path to a config file and a loader, attempt to load the config, handle errors + appropriately.""" + if cfg_info.path is None and optional: + return None + + if cfg_info.path is None and not optional: + similar = self.find_similar_configs(cfg_info.type, cfg_info.name) + + if similar: + raise TestConfigError( + "Could not find {} config {}.yaml.\n" + "Did you mean one of these? {}" + .format(cfg_info.type, cfg_info.name, ', '.join(similar))) + else: + raise TestConfigError( + "Could not find {0} config file '{1}.yaml' in any of the " + "Pavilion config directories.\n" + "Run `pav show {2}` to get a list of available {0} files." + .format(cfg_info.type, cfg_info.name, cfg_info.type)) + + raw_cfg = self._safe_load_config(cfg_info, loader) + + if cfg_info.from_suite and cfg_info.type != "suite": + raw_cfg = raw_cfg.get(cfg_info.name) + + if raw_cfg is None and not optional: + raise TestConfigError( + f"Could not find {cfg_info.type} config with name {cfg_info.name}" + f" in file {cfg_info.path}.") + + return raw_cfg def _load_raw_configs(self, request: TestRequest, modes: List[str], conditions: Dict, overrides: List[str]) \ @@ -636,7 +721,9 @@ def _load_raw_configs(self, request: TestRequest, modes: List[str], # Apply modes. try: - test_cfg = self.apply_modes(test_cfg, modes) + test_cfg = self.apply_os(test_cfg, self._os, request.suite) + test_cfg = self.apply_host(test_cfg, self._host, request.suite) + test_cfg = self.apply_modes(test_cfg, modes, request.suite) except TestConfigError as err: err.request = request self.errors.append(err) @@ -668,7 +755,7 @@ def _load_raw_configs(self, request: TestRequest, modes: List[str], test_cfg['result_evaluate'][key] = '"{}"'.format(const) - test_cfg = self._validate(test_name, test_cfg) + test_cf = self._validate(test_name, test_cfg) # Now that we've applied all general transforms to the config, make it into a ProtoTest. try: @@ -726,42 +813,56 @@ def _validate(self, test_name: str, test_cfg: Dict) -> Dict: return test_cfg - def _load_base_config(self, op_sys, host) -> Dict: + def _load_base_config(self, op_sys: str, host: str) -> TestConfig: """Load the base configuration for the given host. This is done once and saved.""" # Get the base, empty config, then apply the host config on top of it. base_config = self._loader.load_empty() base_config = self.apply_os(base_config, op_sys) + return self.apply_host(base_config, host) - def _load_suite_tests(self, suite: str): + def _load_suite_tests(self, suite_name: str): """Load the suite config, with standard info applied to """ - if suite in self._suites: - return self._suites[suite] + if suite_name in self._suites: + return self._suites[suite_name] + + cfg_info = self.find_config("suite", suite_name, suite_name) + + if cfg_info.from_suite: + loader = self._suite_loader + else: + loader = self._loader + + raw_suite_cfg = self._load_raw_config(cfg_info, loader) - raw_suite_cfg, suite_path, cfg_label = self._load_raw_config(suite, 'suite') # Make sure each test has a dict as contents. for test_name, raw_test in raw_suite_cfg.items(): if raw_test is None: raw_suite_cfg[test_name] = {} - suite_tests = self.resolve_inheritance(raw_suite_cfg, suite_path) + suite_tests = self.resolve_inheritance(raw_suite_cfg, cfg_info.path) # Perform essential transformations to each test config. for test_cfg_name, test_cfg in list(suite_tests.items()): # Basic information that all test configs should have. test_cfg['name'] = test_cfg_name - test_cfg['cfg_label'] = cfg_label - working_dir = self.pav_cfg['configs'][cfg_label]['working_dir'] + test_cfg['cfg_label'] = cfg_info.label + working_dir = self.pav_cfg['configs'][cfg_info.label]['working_dir'] test_cfg['working_dir'] = working_dir.as_posix() - test_cfg['suite'] = suite - test_cfg['suite_path'] = suite_path.as_posix() + test_cfg['suite'] = suite_name test_cfg['host'] = self._host test_cfg['os'] = self._os - self._suites[suite] = suite_tests + if cfg_info.from_suite: + test_cfg['suite_path'] = cfg_info.path.parent.as_posix() + else: + test_cfg['suite_path'] = cfg_info.path.as_posix() + + self._suites[suite_name] = suite_tests + return suite_tests def _reset_schedulers(self): @@ -831,65 +932,98 @@ def check_version_compatibility(self, test_cfg): "Incompatible with pavilion version '{}', compatible versions " "'{}'.".format(PavVars()['version'], comp_versions)) - def apply_host(self, test_cfg, host): + def apply_host(self, test_cfg: TestConfig, hostname: str, suite_name: str = None) -> TestConfig: """Apply the host configuration to the given config.""" - loader = self._loader + if suite_name is not None: + from_suite = True + label, host_cfg_path = self._config_path_from_suite(suite_name, "host") + loader = self._suite_loader + else: + from_suite = False + label, host_cfg_path = self._get_test_config_path(hostname, "host") + loader = self._loader + + cfg_info = ConfigInfo(hostname, "host", host_cfg_path, label, from_suite) + + raw_host_cfg = self._load_raw_config(cfg_info, loader, optional=True) - raw_host_cfg, host_cfg_path, _ = self._load_raw_config(host, 'host', optional=True) if raw_host_cfg is None: return test_cfg try: - host_cfg = loader.normalize( + host_cfg = self._loader.normalize( raw_host_cfg, root_name=f"the top level of the host file.") except (KeyError, ValueError) as err: raise TestConfigError( - f"Error loading host config '{host}' from file '{host_cfg_path}'.") + f"Error loading host config '{hostname}' from file '{host_cfg_path}'.") try: - return loader.merge(test_cfg, host_cfg) + return self._loader.merge(test_cfg, host_cfg) except (KeyError, ValueError) as err: raise TestConfigError( - "Error merging host configuration for host '{}'".format(host)) + "Error merging host configuration for host '{}'".format(hostname)) - def apply_os(self, test_cfg, op_sys): + def apply_os(self, test_cfg: TestConfig, op_sys: str, suite_name: str = None) -> TestConfig: """Apply the OS configuration to the given config.""" - loader = self._loader + if suite_name is not None: + from_suite = True + label, os_cfg_path = self._config_path_from_suite(suite_name, "OS") + loader = self._suite_loader + else: + from_suite = False + label, os_cfg_path = self._get_test_config_path(op_sys, "OS") + loader = self._loader + + cfg_info = ConfigInfo(op_sys, "OS", os_cfg_path, label, from_suite) + + raw_os_cfg = self._load_raw_config(cfg_info, loader, optional=True) - raw_os_cfg, os_cfg_path, _ = self._load_raw_config(op_sys, 'OS', optional=True) if raw_os_cfg is None: return test_cfg try: - os_cfg = loader.normalize( + os_cfg = self._loader.normalize( raw_os_cfg, root_name=f"the top level of the OS file.") except (KeyError, ValueError) as err: raise TestConfigError( - f"Error loading host config '{op_sys}' from file '{os_cfg_path}'.") + f"Error loading host config '{op_sys}' from file '{os_cfg_path}'") try: - return loader.merge(test_cfg, os_cfg) + return self._loader.merge(test_cfg, os_cfg) except (KeyError, ValueError) as err: raise TestConfigError( "Error merging configuration for OS '{}'".format(os)) - def apply_modes(self, test_cfg, modes: List[str]): + def apply_modes(self, test_cfg, modes: List[str], suite_name: str = None): """Apply each of the mode files to the given test config. :param test_cfg: The raw test configuration. :param modes: A list of mode names. """ - loader = self._loader - for mode in modes: - raw_mode_cfg, mode_cfg_path, _ = self._load_raw_config(mode, 'mode') + mode_cfg_path = None + + if suite_name is not None: + label, mode_cfg_path = self._config_path_from_suite(suite_name, "mode") + if mode_cfg_path is None: + from_suite = False + label, mode_cfg_path = self._get_test_config_path(mode, "mode") + loader = self._loader + else: + from_suite = True + loader = self._suite_loader + + cfg_info = ConfigInfo(mode, "mode", mode_cfg_path, label, from_suite) + + raw_mode_cfg = self._load_raw_config(cfg_info, loader) + try: - mode_cfg = loader.normalize( + mode_cfg = self._loader.normalize( raw_mode_cfg, root_name=f"the top level of the OS file.") except (KeyError, ValueError) as err: @@ -897,7 +1031,7 @@ def apply_modes(self, test_cfg, modes: List[str]): f"Error loading host config '{mode}' from file '{mode_cfg_path}'.") try: - test_cfg = loader.merge(test_cfg, mode_cfg) + test_cfg = self._loader.merge(test_cfg, mode_cfg) except (KeyError, ValueError) as err: raise TestConfigError( "Error merging mode configuration for mode '{}'".format(mode)) diff --git a/lib/pavilion/series/series.py b/lib/pavilion/series/series.py index f0087607e..b3b44d02f 100644 --- a/lib/pavilion/series/series.py +++ b/lib/pavilion/series/series.py @@ -526,17 +526,19 @@ def _run_set(self, test_set: TestSet, build_only: bool, rebuild: bool, local_bui WAIT_INTERVAL = 0.5 - def wait(self, timeout=None): + def wait(self, timeout: float = None) -> None: """Wait for the series to be complete or the timeout to expire. """ if timeout is None: end = math.inf else: end = time.time() + timeout + while time.time() < end: if self.complete: return - time.sleep(2) + + time.sleep(self.WAIT_INTERVAL) raise TimeoutError("Series {} did not complete before timeout." .format(self._id)) diff --git a/lib/pavilion/series/test_set.py b/lib/pavilion/series/test_set.py index d9bf971e4..7d5fe40fc 100644 --- a/lib/pavilion/series/test_set.py +++ b/lib/pavilion/series/test_set.py @@ -5,6 +5,7 @@ import os import threading import time +import math from collections import defaultdict from io import StringIO from typing import List, Dict, TextIO, Union, Set, Iterator, Tuple @@ -19,6 +20,7 @@ from pavilion.utils import str_bool from pavilion.enums import Verbose from pavilion.jobs import Job +from pavilion.micro import set_default S_STATES = SERIES_STATES @@ -713,7 +715,8 @@ def mark_completed(self) -> int: TEST_WAIT_PERIOD = 0.5 - def wait(self, wait_for_all=False, wait_period: int = TEST_WAIT_PERIOD) -> int: + def wait(self, wait_for_all=False, wait_period: int = TEST_WAIT_PERIOD, + timeout: int = None) -> int: """Wait for tests to complete. Returns the number of jobs that completed for this call to wait. @@ -722,14 +725,22 @@ def wait(self, wait_for_all=False, wait_period: int = TEST_WAIT_PERIOD) -> int: :return: The number of completed tests """ + timeout = set_default(timeout, math.inf) + # No tests to wait for if not self.started_tests: return 0 completed_tests = self.mark_completed() + start = time.time() + while ((wait_for_all and self.started_tests) or (not wait_for_all and completed_tests == 0)): + + if time.time() - start > timeout: + raise TimeoutError("Timed out waiting for test set to complete.") + time.sleep(wait_period) completed_tests += self.mark_completed() diff --git a/lib/pavilion/series_config/__init__.py b/lib/pavilion/series_config/__init__.py index 79a230d43..32231f15e 100644 --- a/lib/pavilion/series_config/__init__.py +++ b/lib/pavilion/series_config/__init__.py @@ -82,7 +82,9 @@ def load_series_config(pav_cfg, series_name: str) -> dict: series_config_loader = SeriesConfigLoader() resolver = TestConfigResolver(pav_cfg) - _, series_file_path = resolver.find_config('series', series_name) + cfg_info = resolver.find_config('series', series_name) + + series_file_path = cfg_info.path if not series_file_path: raise SeriesConfigError('Cannot find series config: {}'. diff --git a/lib/pavilion/test_config/file_format.py b/lib/pavilion/test_config/file_format.py index 6e7cdb240..fe48d0374 100644 --- a/lib/pavilion/test_config/file_format.py +++ b/lib/pavilion/test_config/file_format.py @@ -623,7 +623,7 @@ class TestConfigLoader(yc.YamlConfigLoader): sub_elem=yc.StrElem(), help_text="Template files to resolve using Pavilion test variables. The " "key is the path to the template file (typically with a " - "'.pav' extension), in the 'test_src' directory. The value is the " + "'.pav' extension), in the 'suites' directory. The value is the " "output file location, relative to the test's build/run " "directory."), EnvCatElem( @@ -657,7 +657,7 @@ class TestConfigLoader(yc.YamlConfigLoader): "uncompressed archive (zip/tar), and is handled " "according to the internal (file-magic) type. " "For relative paths Pavilion looks in the " - "test_src directory " + "suites directory " "within all known config directories. If this " "is left blank, Pavilion will always assume " "there is no source to build."), @@ -669,7 +669,7 @@ class TestConfigLoader(yc.YamlConfigLoader): 'can\'t otherwise be found. You must give a ' 'source path so Pavilion knows where to store ' 'the file (relative paths will be stored ' - 'relative to the local test_src directory.'), + 'relative to the local suites directory.'), yc.StrElem( 'source_download', choices=['never', 'missing', 'latest'], default='missing', @@ -773,7 +773,7 @@ class TestConfigLoader(yc.YamlConfigLoader): "file. Pavilion test variables will be resolved in this lines " "before they are written."), - # Note - Template have to come from the test_src directory (or elsewhere on + # Note - Template have to come from the suites directory (or elsewhere on # the filesystem, because we have to be able to process them before # we can create a build hash. PathCategoryElem( @@ -782,7 +782,7 @@ class TestConfigLoader(yc.YamlConfigLoader): sub_elem=yc.StrElem(), help_text="Template files to resolve using Pavilion test variables. The " "key is the path to the template file (typically with a " - "'.pav' extension), in the 'test_src' directory. The value is the " + "'.pav' extension), in the 'suites' directory. The value is the " "output file location, relative to the test's build/run " "directory."), EnvCatElem( diff --git a/lib/pavilion/test_run/test_run.py b/lib/pavilion/test_run/test_run.py index 9b093a5cc..18b6a5025 100644 --- a/lib/pavilion/test_run/test_run.py +++ b/lib/pavilion/test_run/test_run.py @@ -13,7 +13,7 @@ import time import uuid from pathlib import Path -from typing import TextIO, Union, Dict +from typing import TextIO, Union, Dict, Optional import yc_yaml as yaml from pavilion.config import PavConfig @@ -36,6 +36,7 @@ from pavilion.test_config.file_format import NO_WORKING_DIR from pavilion.test_config.utils import parse_timeout from pavilion.types import ID_Pair +from pavilion.micro import get_nested from .test_attrs import TestAttributes @@ -152,10 +153,12 @@ def __init__(self, pav_cfg: PavConfig, config: Dict, var_man: VariableSetManager self.rebuild = rebuild self.cfg_label = config.get('cfg_label', self.NO_LABEL) suite_path = config.get('suite_path') - if suite_path == '' or suite_path is None: - self.suite_path = Path('..') + + if suite_path is None or suite_path == '': + self.suite_path = None else: self.suite_path = Path(suite_path) + self.user = utils.get_login() self.uuid = str(uuid.uuid4()) @@ -251,6 +254,12 @@ def is_empty(section: str) -> bool: return not all(map(is_empty, sections)) + @property + def suite_name(self) -> Optional[str]: + """Return the name of the suite associated with the test.""" + if self.suite_path is not None: + return self.suite_path.stem + @property def id_pair(self) -> ID_Pair: """Returns an ID_pair (a tuple of the working dir and test id).""" @@ -336,17 +345,27 @@ def _make_builder(self) -> builder.TestBuilder: spack_config = (self.config.get('spack_config', {}) if self.spack_enabled() else None) - if self.suite_path != Path('..') and self.suite_path is not None: - download_dest = self.suite_path.parents[1] / 'test_src' + + if self.suite_path is not None: + download_dest = self.suite_path + + # Check the deprecated directory + if not download_dest.exists() or not download_dest.is_dir(): + download_dest = self.suite_path.parents[1] / 'test_src' else: download_dest = None templates = self._create_build_templates() + config = self.config.get('build', {}) + + if self.suite_name is not None: + config['suite_name'] = self.suite_name + try: test_builder = builder.TestBuilder( pav_cfg=self._pav_cfg, - config=self.config.get('build', {}), + config=config, script=self.build_script_path, spack_config=spack_config, status=self.status, @@ -366,9 +385,10 @@ def _make_builder(self) -> builder.TestBuilder: def _create_build_templates(self) -> Dict[Path, Path]: """Generate templated files for the builder to use.""" - templates = self.config.get('build', {}).get('templates', {}) + templates = get_nested(['build', 'templates'], self.config) tmpl_dir = self.path/self.BUILD_TEMPLATE_DIR - if templates: + + if templates != {}: if not tmpl_dir.exists(): try: tmpl_dir.mkdir(exist_ok=True) @@ -376,6 +396,7 @@ def _create_build_templates(self) -> Dict[Path, Path]: raise TestRunError("Could not create build template directory", err) tmpl_paths = {} + for tmpl_src, tmpl_dest in templates.items(): if not (tmpl_dir/tmpl_dest).exists(): try: @@ -383,6 +404,7 @@ def _create_build_templates(self) -> Dict[Path, Path]: create_files.create_file(tmpl_dest, tmpl_dir, tmpl, newlines='') except TestConfigError as err: raise TestRunError("Error resolving Build template files", err) + tmpl_paths[tmpl_dir/tmpl_dest] = tmpl_dest return tmpl_paths diff --git a/test/data/pav_config_dir/suites/bare_yaml.yaml b/test/data/pav_config_dir/suites/bare_yaml.yaml new file mode 100644 index 000000000..8aa715440 --- /dev/null +++ b/test/data/pav_config_dir/suites/bare_yaml.yaml @@ -0,0 +1,4 @@ +bare_yaml: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/basic_suite_test/suite.yaml b/test/data/pav_config_dir/suites/basic_suite_test/suite.yaml new file mode 100644 index 000000000..24205bb82 --- /dev/null +++ b/test/data/pav_config_dir/suites/basic_suite_test/suite.yaml @@ -0,0 +1,4 @@ +basic_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/circular_symlinks/bar b/test/data/pav_config_dir/suites/circular_symlinks/bar new file mode 120000 index 000000000..191028156 --- /dev/null +++ b/test/data/pav_config_dir/suites/circular_symlinks/bar @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/test/data/pav_config_dir/suites/circular_symlinks/foo b/test/data/pav_config_dir/suites/circular_symlinks/foo new file mode 120000 index 000000000..ba0e162e1 --- /dev/null +++ b/test/data/pav_config_dir/suites/circular_symlinks/foo @@ -0,0 +1 @@ +bar \ No newline at end of file diff --git a/test/data/pav_config_dir/suites/circular_symlinks/suite.yaml b/test/data/pav_config_dir/suites/circular_symlinks/suite.yaml new file mode 100644 index 000000000..dbcc128b7 --- /dev/null +++ b/test/data/pav_config_dir/suites/circular_symlinks/suite.yaml @@ -0,0 +1,7 @@ +circular_symlink: + summary: Build for which the directory contains circular symlinks. + scheduler: raw + build: + source_path: "." + run: + cmds: "echo hello" diff --git a/test/data/pav_config_dir/suites/hash_suite_test_a/fox.txt b/test/data/pav_config_dir/suites/hash_suite_test_a/fox.txt new file mode 100644 index 000000000..458d9cb93 --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_a/fox.txt @@ -0,0 +1 @@ +The quick brown fox jumped over the lazy dog diff --git a/test/data/pav_config_dir/suites/hash_suite_test_a/hosts.yaml b/test/data/pav_config_dir/suites/hash_suite_test_a/hosts.yaml new file mode 100644 index 000000000..b65a28130 --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_a/hosts.yaml @@ -0,0 +1,3 @@ +hash_host: + variables: + foo: True diff --git a/test/data/pav_config_dir/suites/hash_suite_test_a/modes.yaml b/test/data/pav_config_dir/suites/hash_suite_test_a/modes.yaml new file mode 100644 index 000000000..5074751e2 --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_a/modes.yaml @@ -0,0 +1,3 @@ +hash_mode: + variables: + foo: True diff --git a/test/data/pav_config_dir/suites/hash_suite_test_a/os.yaml b/test/data/pav_config_dir/suites/hash_suite_test_a/os.yaml new file mode 100644 index 000000000..692d50284 --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_a/os.yaml @@ -0,0 +1,3 @@ +hash_os: + variables: + foo: True diff --git a/test/data/pav_config_dir/suites/hash_suite_test_a/suite.yaml b/test/data/pav_config_dir/suites/hash_suite_test_a/suite.yaml new file mode 100644 index 000000000..97ea92e4b --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_a/suite.yaml @@ -0,0 +1,4 @@ +hash_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/hash_suite_test_b/fox.txt b/test/data/pav_config_dir/suites/hash_suite_test_b/fox.txt new file mode 100644 index 000000000..458d9cb93 --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_b/fox.txt @@ -0,0 +1 @@ +The quick brown fox jumped over the lazy dog diff --git a/test/data/pav_config_dir/suites/hash_suite_test_b/suite.yaml b/test/data/pav_config_dir/suites/hash_suite_test_b/suite.yaml new file mode 100644 index 000000000..97ea92e4b --- /dev/null +++ b/test/data/pav_config_dir/suites/hash_suite_test_b/suite.yaml @@ -0,0 +1,4 @@ +hash_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/hosts_suite_test/hosts.yaml b/test/data/pav_config_dir/suites/hosts_suite_test/hosts.yaml new file mode 100644 index 000000000..5fd87ad2e --- /dev/null +++ b/test/data/pav_config_dir/suites/hosts_suite_test/hosts.yaml @@ -0,0 +1,7 @@ +host1: + variables: + host1: True + +host2: + variables: + host2: True diff --git a/test/data/pav_config_dir/suites/hosts_suite_test/suite.yaml b/test/data/pav_config_dir/suites/hosts_suite_test/suite.yaml new file mode 100644 index 000000000..b7749cd6b --- /dev/null +++ b/test/data/pav_config_dir/suites/hosts_suite_test/suite.yaml @@ -0,0 +1,4 @@ +hosts_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/modes_suite_test/modes.yaml b/test/data/pav_config_dir/suites/modes_suite_test/modes.yaml new file mode 100644 index 000000000..c14c31d04 --- /dev/null +++ b/test/data/pav_config_dir/suites/modes_suite_test/modes.yaml @@ -0,0 +1,7 @@ +mode1: + variables: + mode1: True + +mode2: + variables: + mode2: True diff --git a/test/data/pav_config_dir/suites/modes_suite_test/suite.yaml b/test/data/pav_config_dir/suites/modes_suite_test/suite.yaml new file mode 100644 index 000000000..abeda51e8 --- /dev/null +++ b/test/data/pav_config_dir/suites/modes_suite_test/suite.yaml @@ -0,0 +1,4 @@ +modes_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/os_suite_test/os.yaml b/test/data/pav_config_dir/suites/os_suite_test/os.yaml new file mode 100644 index 000000000..130918f31 --- /dev/null +++ b/test/data/pav_config_dir/suites/os_suite_test/os.yaml @@ -0,0 +1,7 @@ +os1: + variables: + os1: True + +os2: + variables: + os2: True diff --git a/test/data/pav_config_dir/suites/os_suite_test/suite.yaml b/test/data/pav_config_dir/suites/os_suite_test/suite.yaml new file mode 100644 index 000000000..39fc5a13a --- /dev/null +++ b/test/data/pav_config_dir/suites/os_suite_test/suite.yaml @@ -0,0 +1,4 @@ +os_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/data/pav_config_dir/suites/suite_with_source/hello.c b/test/data/pav_config_dir/suites/suite_with_source/hello.c new file mode 100644 index 000000000..87a87b0b3 --- /dev/null +++ b/test/data/pav_config_dir/suites/suite_with_source/hello.c @@ -0,0 +1,7 @@ +#include + +int main() { + printf("Hello, World!\n"); + + return 0; +} diff --git a/test/data/pav_config_dir/suites/suite_with_source/suite.yaml b/test/data/pav_config_dir/suites/suite_with_source/suite.yaml new file mode 100644 index 000000000..495610976 --- /dev/null +++ b/test/data/pav_config_dir/suites/suite_with_source/suite.yaml @@ -0,0 +1,6 @@ +suite_with_source: + scheduler: raw + build: + cmds: "gcc -o hello hello.c" + run: + cmds: "{{sched.test_cmd}} ./hello" diff --git a/test/data/pav_config_dir/tests/hash_suite_test.yaml b/test/data/pav_config_dir/tests/hash_suite_test.yaml new file mode 100644 index 000000000..97ea92e4b --- /dev/null +++ b/test/data/pav_config_dir/tests/hash_suite_test.yaml @@ -0,0 +1,4 @@ +hash_suite_test: + scheduler: raw + run: + cmds: 'echo Hello!' diff --git a/test/tests/builder_tests.py b/test/tests/builder_tests.py index eb975d582..eae6b909e 100644 --- a/test/tests/builder_tests.py +++ b/test/tests/builder_tests.py @@ -9,15 +9,18 @@ import uuid from pathlib import Path -from pavilion import builder -from pavilion import wget +from pavilion import builder, arguments, wget, plugins, commands from pavilion.build_tracker import MultiBuildTracker, DummyTracker -from pavilion.errors import TestRunError +from pavilion.errors import TestRunError, TestBuilderError from pavilion.status_file import STATES from pavilion.unittest import PavTestCase class BuilderTests(PavTestCase): + def setUp(self): + plugins.initialize_plugins(self.pav_cfg) + build_cmd = commands.get_command('run') + build_cmd.silence() def test_build_locking(self): """Make sure multiple builds of the same hash lock each other out.""" @@ -451,3 +454,24 @@ def test_create_files(self): with self.assertRaises(TestRunError): self._quick_test(cfg) + + def test_hash_circular_symlinks(self): + """Test that the builder errors out when trying to hash a directory + containing circular symlinks.""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + '-H', 'this', + 'circular_symlinks' + ]) + + run_cmd = commands.get_command(args.command_name) + + run_cmd.run(self.pav_cfg, args) + + last_series = run_cmd.last_series + + last_series.wait() + + self.assertEqual(last_series.status.current().state, "ERROR") diff --git a/test/tests/config_cmd_tests.py b/test/tests/config_cmd_tests.py index 922283da1..26a9ca857 100644 --- a/test/tests/config_cmd_tests.py +++ b/test/tests/config_cmd_tests.py @@ -49,12 +49,12 @@ def test_config_cmds(self): self.assertTrue('main' in pav_cfg.configs) self.assertEqual(pav_cfg.working_dir, test_config_wd) # Make sure all created files exist. - for subfile in ['config.yaml', 'test_src', 'tests', 'hosts', 'modes', 'plugins', + for subfile in ['config.yaml', 'suites', 'hosts', 'modes', 'plugins', 'pavilion.yaml', 'os']: self.assertTrue((test_config_root / subfile).exists()) # Make sure groups are sane. if other_group is not None: - for path in (test_config_root, test_config_root/'tests', test_pav_config_path, + for path in (test_config_root, test_config_root/'suites', test_pav_config_path, pav_cfg.working_dir): self.assertEqual(path.group(), other_group, msg="Path '{}' should have group '{}', but had group '{}'" @@ -70,7 +70,7 @@ def test_config_cmds(self): foo_config_dir = test_config_root/'foo' args = arg_parser.parse_args(['config', 'create', 'foo', foo_config_dir.as_posix()]) self.assertEqual(config_cmd.run(pav_cfg, args), 0) - for subfile in ['config.yaml', 'test_src', 'tests', 'hosts', 'modes', 'plugins', + for subfile in ['config.yaml', 'suites', 'hosts', 'modes', 'plugins', 'os']: self.assertTrue((foo_config_dir/subfile).exists()) diff --git a/test/tests/filter_tests.py b/test/tests/filter_tests.py index fc4f23d1b..845e2e44e 100644 --- a/test/tests/filter_tests.py +++ b/test/tests/filter_tests.py @@ -17,7 +17,7 @@ from pavilion.status_file import TestStatusFile, SeriesStatusFile from pavilion.filters import (FilterParseError, validate_int, validate_glob, validate_glob_list, validate_str_list, validate_datetime, - parse_query, parse_duration) + parse_query, parse_duration, safe_update) class FiltersTest(PavTestCase): @@ -500,15 +500,15 @@ def test_parse_duration(self): self.assertTrue(parse_duration('-0days', now) == now) parsed = parse_duration('13 months', now) - expected = date(year=now.year - 1, month=now.month - 1, day=now.day) + expected = safe_update(now, year=now.year - 1, month=now.month - 1).date() self.assertTrue(parsed.date() == expected) parsed = parse_duration('12 months', now) - expected = date(year=now.year - 1, month=now.month, day=now.day) + expected = safe_update(now, year=now.year - 1).date() self.assertTrue(parsed.date() == expected) parsed = parse_duration('1 months', now) - expected = date(year=now.year, month=now.month - 1, day=now.day) + expected = safe_update(now, month=now.month - 1).date() self.assertTrue(parsed.date() == expected) def test_filter_keywords_implemented(self): diff --git a/test/tests/general_tests.py b/test/tests/general_tests.py index 01ff6050b..09e5aae37 100644 --- a/test/tests/general_tests.py +++ b/test/tests/general_tests.py @@ -31,11 +31,9 @@ def __init__(self, *args, **kwargs): if not candidates: self.orig_group = None self.alt_group = None - self.alt_group2 = None else: self.orig_group = grp.getgrgid(def_gid).gr_name self.alt_group = candidates[0] # type: grp.struct_group - self.alt_group2 = candidates[1] # type: grp.struct_group self.umask = 0o007 diff --git a/test/tests/group_tests.py b/test/tests/group_tests.py index 401a7abac..08399c824 100644 --- a/test/tests/group_tests.py +++ b/test/tests/group_tests.py @@ -220,8 +220,8 @@ def test_group_commands(self): run_cmd.run(self.pav_cfg, run_args) series_cmd.run(self.pav_cfg, series_args) - run_cmd.last_series.wait() - series_cmd.last_series.wait() + run_cmd.last_series.wait(timeout=10) + series_cmd.last_series.wait(timeout=10) group = groups.TestGroup(self.pav_cfg, group_name) self.assertTrue(group.exists()) @@ -230,13 +230,13 @@ def test_group_commands(self): # Prep some separate tests to add run_args2 = parser.parse_args(['run', 'hello_world']) run_cmd.run(self.pav_cfg, run_args2) - run_cmd.last_series.wait() + run_cmd.last_series.wait(timeout=10) # Create a new group with tests to add sub_group_name = self._make_group_name() run_args3 = parser.parse_args(['run', '-g', sub_group_name, 'hello_world']) run_cmd.run(self.pav_cfg, run_args3) - run_cmd.last_series.wait() + run_cmd.last_series.wait(timeout=10) add_items = [sub_group_name] + [test.full_id for test in run_cmd.last_tests] rm_tests = add_items[1:3] diff --git a/test/tests/run_cmd_tests.py b/test/tests/run_cmd_tests.py index b06e6caa8..461895792 100644 --- a/test/tests/run_cmd_tests.py +++ b/test/tests/run_cmd_tests.py @@ -232,7 +232,7 @@ def test_concurrent(self): out, err = run_cmd.clear_output() self.assertEqual(run_cmd.run(self.pav_cfg, args), 0, msg=out+err) - run_cmd.last_series.wait() + run_cmd.last_series.wait(timeout=10) # The test fails if it ever catches more tests running than its concurrency limit for test in run_cmd.last_tests: diff --git a/test/tests/series_cmd_tests.py b/test/tests/series_cmd_tests.py index ddd11bd7d..ef2df6f04 100644 --- a/test/tests/series_cmd_tests.py +++ b/test/tests/series_cmd_tests.py @@ -36,7 +36,7 @@ def test_run_series(self): run_result = run_cmd.run(self.pav_cfg, args) self.assertEqual(run_result, 0) - run_cmd.last_run_series.wait() + run_cmd.last_run_series.wait(timeout=10) self.assertEqual(run_cmd.last_run_series.complete, True) self.assertEqual(run_cmd.last_run_series.info().passed, 1) @@ -59,7 +59,7 @@ def test_run_series_modes(self): self.assertEqual(run_result, 0) series_obj = run_cmd.last_run_series - series_obj.wait(5) + series_obj.wait(timeout=10) self.assertEqual(series_obj.complete, True) self.assertEqual(series_obj.info().passed, 1) @@ -86,7 +86,7 @@ def test_run_series_overrides(self): self.assertEqual(run_result, 0) series_obj = run_cmd.last_run_series - series_obj.wait(5) + series_obj.wait(timeout=10) self.assertEqual(series_obj.complete, True) self.assertEqual(series_obj.info().passed, 1) @@ -128,7 +128,7 @@ def test_series_sets(self): args = arg_parser.parse_args(['series', 'run', 'multi']) self.assertEqual(series_cmd.run(self.pav_cfg, args), 0) - series_cmd.last_run_series.wait() + series_cmd.last_run_series.wait(timeout=10) sid = series_cmd.last_run_series.sid arg_lists = [ diff --git a/test/tests/series_tests.py b/test/tests/series_tests.py index 212881099..cc7401a4b 100644 --- a/test/tests/series_tests.py +++ b/test/tests/series_tests.py @@ -283,7 +283,7 @@ def test_ignore_errors(self): series_obj = series.TestSeries(self.pav_cfg, series_cfg=cfg) series_obj.run() - series_obj.wait() + series_obj.wait(timeout=10) for test in series_obj.tests.values(): if test.name in ['test_set_errors.good', 'hello_world.hello']: diff --git a/test/tests/status_cmd_tests.py b/test/tests/status_cmd_tests.py index 928ecc073..756bd36a5 100644 --- a/test/tests/status_cmd_tests.py +++ b/test/tests/status_cmd_tests.py @@ -1,6 +1,8 @@ import argparse import io import time +import re +from typing import List from pavilion import commands from pavilion import plugins @@ -11,6 +13,9 @@ from pavilion.unittest import PavTestCase +STATUS_REGEX = re.compile(r"^\s*[A-Z]") + + class StatusCmdTests(PavTestCase): def setUp(self): @@ -273,6 +278,7 @@ def test_status_history(self): raw = schedulers.get_plugin('raw') raw.schedule_tests(self.pav_cfg, [test]) end = time.time() + 5 + while not test.complete and time.time() < end: time.sleep(.1) @@ -282,7 +288,12 @@ def test_status_history(self): out.seek(0) output = out.readlines()[4:] statuses = test.status.history() + + # Some statuses are split over multiple lines, but only in CI for some reason + output = list(filter(lambda x: re.search(STATUS_REGEX, x), output)) + self.assertEqual(len(output), len(statuses), msg='output: {}, statuses: {}' .format(output, statuses)) + for i in range(len(output)): self.assertTrue(statuses[i].state in output[i]) diff --git a/test/tests/suites_tests.py b/test/tests/suites_tests.py new file mode 100644 index 000000000..c153ede14 --- /dev/null +++ b/test/tests/suites_tests.py @@ -0,0 +1,166 @@ +from pavilion import arguments +from pavilion import commands +from pavilion import plugins +from pavilion.unittest import PavTestCase + + +class SuitesTests(PavTestCase): + + def setUp(self): + plugins.initialize_plugins(self.pav_cfg) + run_cmd = commands.get_command('run') + build_cmd = commands.get_command('build') + run_cmd.silence() + + def test_suite_run_from_suite_directory(self): + """Test that Pavilion can find and run a test from + a named suites directory.""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + '-H', 'this', + 'basic_suite_test' + ]) + + run_cmd = commands.get_command(args.command_name) + + self.assertEqual(run_cmd.run(self.pav_cfg, args), 0) + + def test_suite_run_from_bare_yaml(self): + """Test that Pavilion can find and run a test from + a bare yaml file in the suites directory.""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + '-H', 'this', + 'bare_yaml' + ]) + + run_cmd = commands.get_command(args.command_name) + + self.assertEqual(run_cmd.run(self.pav_cfg, args), 0) + + def test_suites_host_config(self): + """Test that Pavilion loads host configs from the + suites directory""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + '-H', 'host1', + 'hosts_suite_test' + ]) + + run_cmd = commands.get_command(args.command_name) + # How do I resolve the configs without building the test? + ret = run_cmd.run(self.pav_cfg, args) + + self.assertEqual(ret, 0) + + last_test = run_cmd.last_tests[0] + + self.assertTrue(last_test.config["host"] == "host1") + + variables = last_test.config["variables"] + + self.assertEqual(variables.get("host1")[0].get(None), "True") + + def test_suites_mode_config(self): + """Test that Pavilion loads mode configs from the + suites directory""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + '-m', 'mode1', + 'modes_suite_test' + ]) + + run_cmd = commands.get_command(args.command_name) + # How do I resolve the configs without building the test? + ret = run_cmd.run(self.pav_cfg, args) + + self.assertEqual(ret, 0) + + last_test = run_cmd.last_tests[0] + + self.assertTrue("mode1" in last_test.config["modes"]) + + variables = last_test.config["variables"] + + self.assertEqual(variables.get("mode1")[0].get(None), "True") + + def test_suites_os_config(self): + """Test that Pavilion loads OS configs from the + suites directory""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + '-o', 'os1', + 'os_suite_test' + ]) + + run_cmd = commands.get_command(args.command_name) + # How do I resolve the configs without building the test? + ret = run_cmd.run(self.pav_cfg, args) + + self.assertEqual(ret, 0) + + last_test = run_cmd.last_tests[0] + + self.assertTrue(last_test.config["os"] == "os1") + + variables = last_test.config["variables"] + + self.assertEqual(variables.get("os1")[0].get(None), "True") + + def test_suites_build_hash(self): + """Test that Pavilion ignores config files in the + suites directory when computing the build hash.""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + 'hash_suite_test_a' + ]) + + run_cmd = commands.get_command(args.command_name) + ret = run_cmd.run(self.pav_cfg, args) + + self.assertEqual(ret, 0) + + last_test = run_cmd.last_tests[0] + hash_a = last_test.builder.build_hash + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + 'hash_suite_test_b' + ]) + + run_cmd = commands.get_command(args.command_name) + ret = run_cmd.run(self.pav_cfg, args) + + self.assertEqual(ret, 0) + + last_test = run_cmd.last_tests[0] + hash_b = last_test.builder.build_hash + + self.assertEqual(hash_a, hash_b) + + def test_suite_with_source(self): + """Test that Pavilion correctly finds and uses source files in the suites directory.""" + + arg_parser = arguments.get_parser() + args = arg_parser.parse_args([ + 'run', + 'suite_with_source' + ]) + + run_cmd = commands.get_command(args.command_name) + ret = run_cmd.run(self.pav_cfg, args) + + self.assertEqual(ret, 0) diff --git a/test/tests/testset_tests.py b/test/tests/testset_tests.py index 6ed774d8d..cdc129326 100644 --- a/test/tests/testset_tests.py +++ b/test/tests/testset_tests.py @@ -160,7 +160,7 @@ def test_kickoff(self): ts1.make() ts1.build() self.assertEqual(len(ts1.kickoff()[0]), 10) - ts1.wait(wait_for_all=True) + ts1.wait(wait_for_all=True, timeout=10) # It shouldn't hurt to kickoff when there aren't any tests. ts1.kickoff() @@ -183,7 +183,7 @@ def test_kickoff(self): start_names.sort() exp_names.sort() self.assertEqual(start_names, exp_names) - ts2.wait(wait_for_all=True) + ts2.wait(wait_for_all=True, timeout=10) self.assertEqual(expected, []) @@ -192,7 +192,7 @@ def test_kickoff(self): ts3.make() ts3.build() self.assertEqual(len(ts3.kickoff()[0]), 0) - ts3.wait(wait_for_all=True) + ts3.wait(wait_for_all=True, timeout=10) def test_wait(self): """Checking that we can wait for partial results.""" @@ -201,7 +201,7 @@ def test_wait(self): ts1.make() ts1.build() ts1.kickoff() - self.assertNotEqual(ts1.wait(), 3) + self.assertNotEqual(ts1.wait(timeout=10), 3) def test_all_passed(self): """Make sure we properly verify pass/fail status.""" @@ -210,14 +210,14 @@ def test_all_passed(self): ts1.make() ts1.build() ts1.kickoff() - ts1.wait(wait_for_all=True) + ts1.wait(wait_for_all=True, timeout=10) self.assertFalse(ts1.all_passed) ts2 = TestSet(self.pav_cfg, "test_all_passed2", ["pass_fail.pass"] * 2) ts2.make() ts2.build() ts2.kickoff() - ts2.wait(wait_for_all=True) + ts2.wait(wait_for_all=True, timeout=10) self.assertTrue(ts2.all_passed, msg=[test.result for test in ts2.tests]) def test_cancel(self): @@ -227,7 +227,7 @@ def test_cancel(self): ts1.build() ts1.kickoff() ts1.cancel("Testing cancelation.") - ts1.wait(wait_for_all=True) + ts1.wait(wait_for_all=True, timeout=10) for test in ts1.tests: self.assertEqual(test.status.current().state, test.status.states.CANCELLED, @@ -265,7 +265,7 @@ def test_should_run(self): ts1.make() ts1.build() ts1.kickoff() - ts1.wait(wait_for_all=True) + ts1.wait(wait_for_all=True, timeout=10) self.assertTrue(ts2.should_run) self.assertFalse(ts2_pmp.should_run) self.assertFalse(ts3.should_run) @@ -290,7 +290,7 @@ def test_should_run(self): ts1.make() ts1.build() ts1.kickoff() - ts1.wait(wait_for_all=True) + ts1.wait(wait_for_all=True, timeout=10) self.assertTrue(ts2.should_run) self.assertTrue(ts2_pmp.should_run) self.assertTrue(ts3.should_run) diff --git a/test/tests/utils_tests.py b/test/tests/utils_tests.py index 0610313fc..84652527d 100644 --- a/test/tests/utils_tests.py +++ b/test/tests/utils_tests.py @@ -58,6 +58,7 @@ def test_owner(self): """Check that the owner function works.""" path = Path(tempfile.mktemp()) + with path.open('w') as file: file.write('hi there')