From de9db5d082d1276b019168cf31f3e6141dff8723 Mon Sep 17 00:00:00 2001 From: Paul Ferrell Date: Thu, 15 Jun 2023 15:24:35 -0600 Subject: [PATCH 1/4] Started on group class. --- lib/pavilion/groups.py | 144 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 lib/pavilion/groups.py diff --git a/lib/pavilion/groups.py b/lib/pavilion/groups.py new file mode 100644 index 000000000..e469be95b --- /dev/null +++ b/lib/pavilion/groups.py @@ -0,0 +1,144 @@ +"""Groups are a named collection of series and tests. They can be manipulated +with the `pav group` command.""" + +import re + +from pavilion import config +from pavilion.errors import TestGroupError +from pavilion.test_run import TestRun +from pavilion.series import TestSeries + +class TestGroup: + """A named collection tests and series.""" + + GROUPS_DIR = 'groups' + TESTS_DIR = 'tests' + SERIES_DIR = 'series' + + group_name_re = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]+$') + + def __init__(self, pav_cfg: config.PavConfig, name: str): + + self.pav_cfg = pav_cfg + + if not group_name_re.match(name): + raise TestGroupError( + "Invalid group name '{}'\n" + "Group names must start with a letter, but can otherwise have any " + "combination of letters, numbers, underscores and dashes." + .format(name)) + if name[0] in ('s', 'S') and name[1:].isdigit(): + raise TestGroupError( + "Invalid group name '{}'\n" + "Group name looks too much like a series ID." + .format(name)) + + self.name = name.lower() + self.display_name = name.lower() + + self.path = self.pav_cfg.working_dir/GROUP_DIR/self.truename + + try: + self.path.mkdir(parents=True, exist_ok=True) + except OSError as err: + TestGroupError("Could not create group dir at '{}'" + .format(self.path), prior_error=err) + + for category in self.TESTS_DIR, self.SERIES_DIR, self.GROUP_DIR: + cat_dir = self.path/category + try: + cat_dir.mkdir(exist_ok=True) + except OSError as err: + raise TestGroupError("Could not create group category directory '{}'" + .format(cat_dir.as_posix()) + prior_error=err) + + def add_tests(self, tests: List[Union[TestRun, str, int]]) -> List[TestGroupError]: + """Add the tests to the group. Returns a list of errors encountered. + + :param tests: A list of tests. Can be TestRun objects, a full_id string, or an id integer. + """ + + tests_root = self.path/self.TESTS_DIR + warnings = [] + + for test in tests: + full_id, tpath = self._get_test_info(test) + + tpath = tests_root/test.full_id + + try: + tpath.symlink_to(test.path) + except OSError as err: + warnings.append(TestGroupError( + "Could not add test '{}' to group." + .format(test.full_id), prior_error=err)) + + return warnings + + def _get_test_info(self, test: Union[TestRun, str, int]) -> Tuple[str, Path]: + """Find the test full id and path from the given test information.""" + + if isinstance(test, TestRun): + if not test.path.exists(): + raise TestGroupError("Test '{}' does not exist.".format(test.full_id)) + return test.full_id, test.path + + if isinstance(test, str): + if '.' in test: + cfg_label, test_id = test.split('.', maxsplit=1) + else: + cfg_label = config.DEFAULT_CONFIG_LABEL + test_id = test + + elif isinstance(test, int): + cfg_label = config.DEFAULT_CONFIG_LABEL + test_id = str(int) + + if not test_id.isnumeric(): + raise TestGroupError( + "Invalid test id '{}' from test id '{}'.\n" + "Test id's must be a number, like 27." + .format(test_id, test)) + if cfg_label not in self.pav_cfg.configs: + raise TestGroupError( + "Invalid config label '{}' from test id '{}'.\n" + "No Pavilion configuration directory exists. Valid config " + "labels are:\n {}" + .format(cfg_label, test, + '\n'.join([' - {}'.format(lbl for lbl in self.pav_cfg.configs)]))) + + config = self.pav_cfg.configs[cfg_label] + path = config.working_dir/'test_runs'/test_id + + + def add_series(self, series: List[TestSeries]) -> List[TestGroupError]: + """Add the test series to the group. Returns a list of errors encountered.""" + + series_root = self.path/self.SERIES_DIR + warnings = [] + for ser in series: + spath = series_root/ser.sid + + try: + spath.symlink_to(ser.path) + except OSError as err: + warnings.append(TestGroupError( + "Could not add series '{}' to group." + .format(ser.sid), prior_error=err)) + + return warnings + + def add_groups(self, groups: List["TestGroup"]) -> List[TestGroupError]: + """Add the given groups to the this group.""" + + # Instead of making a symlink, we just touch a file with the group name. + # This prevents symlink loops. + + groups_root = self.path/self.GROUPS_DIR + warnings = [] + + for group in groups: + + + From 3b3205e5c7510023d2939f69cce6dc3875685740 Mon Sep 17 00:00:00 2001 From: Paul Ferrell Date: Tue, 27 Jun 2023 19:53:42 -0600 Subject: [PATCH 2/4] Finished with groups. --- lib/pavilion/clean.py | 52 ++- lib/pavilion/cmd_utils.py | 56 ++- lib/pavilion/commands/__init__.py | 1 + lib/pavilion/commands/clean.py | 13 +- lib/pavilion/commands/group.py | 322 ++++++++++++++ lib/pavilion/commands/run.py | 17 +- lib/pavilion/commands/series.py | 24 +- lib/pavilion/errors.py | 6 +- lib/pavilion/groups.py | 663 ++++++++++++++++++++++------ lib/pavilion/test_run/__init__.py | 2 +- lib/pavilion/test_run/test_attrs.py | 5 +- lib/pavilion/test_run/utils.py | 19 + lib/pavilion/utils.py | 15 + test/tests/group_tests.py | 263 +++++++++++ 14 files changed, 1281 insertions(+), 177 deletions(-) create mode 100644 lib/pavilion/commands/group.py create mode 100644 test/tests/group_tests.py diff --git a/lib/pavilion/clean.py b/lib/pavilion/clean.py index a142fa2e3..705fbfc9b 100644 --- a/lib/pavilion/clean.py +++ b/lib/pavilion/clean.py @@ -2,9 +2,10 @@ import shutil from functools import partial from pathlib import Path -from typing import List +from typing import List, Tuple from pavilion import dir_db +from pavilion import groups from pavilion import lockfile from pavilion import utils from pavilion.builder import TestBuilder @@ -39,7 +40,7 @@ def delete_series(pav_cfg, id_dir: Path, verbose: bool = False) -> int: return dir_db.delete(pav_cfg, id_dir, _delete_series_filter, verbose=verbose) -def delete_builds(pav_cfg, builds_dir: Path, tests_dir: Path, verbose: bool = False): +def delete_unused_builds(pav_cfg, builds_dir: Path, tests_dir: Path, verbose: bool = False): """Delete all build directories that are unused by any test run. :param pav_cfg: The pavilion config. @@ -48,27 +49,6 @@ def delete_builds(pav_cfg, builds_dir: Path, tests_dir: Path, verbose: bool = Fa :param verbose: Bool to determine if verbose output or not. """ - return delete_unused(pav_cfg, tests_dir, builds_dir, verbose) - - -def _filter_unused_builds(used_build_paths: List[Path], build_path: Path) -> bool: - """Return whether a build is not used.""" - return build_path.name not in used_build_paths - - -def delete_unused(pav_cfg, tests_dir: Path, builds_dir: Path, verbose: bool = False) \ - -> (int, List[str]): - """Delete all the build directories, that are unused by any test run. - - :param pav_cfg: The pavilion config. - :param tests_dir: The test_runs directory path object. - :param builds_dir: The builds directory path object. - :param verbose: Print - - :return int count: The number of builds that were removed. - - """ - used_build_paths = _get_used_build_paths(pav_cfg, tests_dir) filter_builds = partial(_filter_unused_builds, used_build_paths) @@ -94,6 +74,32 @@ def delete_unused(pav_cfg, tests_dir: Path, builds_dir: Path, verbose: bool = Fa return count, msgs +def clean_groups(pav_cfg) -> Tuple[int, List[str]]: + """Remove members that no longer exist from groups, and delete empty groups. + Returns the number of groups deleted and a list of error messages.""" + + groups_dir = pav_cfg.working_dir/groups.TestGroup.GROUPS_DIR + + if not groups_dir.exists(): + return 0, [] + + msgs = [] + deleted = 0 + for group_path in groups_dir.iterdir(): + group = groups.TestGroup(pav_cfg, group_path.name) + for error in group.clean(): + msgs.append(error.pformat()) + if not group.exists(): + deleted += 1 + + return deleted, msgs + + +def _filter_unused_builds(used_build_paths: List[Path], build_path: Path) -> bool: + """Return whether a build is not used.""" + return build_path.name not in used_build_paths + + def _get_used_build_paths(pav_cfg, tests_dir: Path) -> set: """Generate a set of all build paths currently used by one or more test runs.""" diff --git a/lib/pavilion/cmd_utils.py b/lib/pavilion/cmd_utils.py index 1b7390a2a..a379cbeef 100644 --- a/lib/pavilion/cmd_utils.py +++ b/lib/pavilion/cmd_utils.py @@ -13,11 +13,12 @@ from pavilion import config from pavilion import dir_db from pavilion import filters +from pavilion import groups from pavilion import output from pavilion import series from pavilion import sys_vars from pavilion import utils -from pavilion.errors import TestRunError, CommandError, TestSeriesError +from pavilion.errors import TestRunError, CommandError, TestSeriesError, TestGroupError from pavilion.test_run import TestRun, test_run_attr_transform, load_tests from pavilion.types import ID_Pair @@ -291,29 +292,24 @@ def test_list_to_paths(pav_cfg, req_tests, errfile=None) -> List[Path]: :return: A list of test id's. """ + if errfile is None: + errfile = io.StringIO() + test_paths = [] for raw_id in req_tests: if raw_id == 'last': raw_id = series.load_user_series_id(pav_cfg, errfile) if raw_id is None: - if errfile: - output.fprint(errfile, "User has no 'last' series for this machine.", - color=output.YELLOW) + output.fprint(errfile, "User has no 'last' series for this machine.", + color=output.YELLOW) continue - if raw_id is None: + if raw_id is None or not raw_id: continue - if '.' not in raw_id and raw_id.startswith('s'): - try: - test_paths.extend( - series.list_series_tests(pav_cfg, raw_id)) - except TestSeriesError: - if errfile: - output.fprint(errfile, "Invalid series id '{}'".format(raw_id), - color=output.YELLOW) - else: + if '.' in raw_id or utils.is_int(raw_id): + # This is a test id. try: test_wd, _id = TestRun.parse_raw_id(pav_cfg, raw_id) except TestRunError as err: @@ -326,6 +322,38 @@ def test_list_to_paths(pav_cfg, req_tests, errfile=None) -> List[Path]: output.fprint(errfile, "Test run with id '{}' could not be found.".format(raw_id), color=output.YELLOW) + elif raw_id[0] == 's' and utils.is_int(raw_id[1:]): + # A series. + try: + test_paths.extend( + series.list_series_tests(pav_cfg, raw_id)) + except TestSeriesError: + output.fprint(errfile, "Invalid series id '{}'".format(raw_id), + color=output.YELLOW) + else: + # A group + try: + group = groups.TestGroup(pav_cfg, raw_id) + except TestGroupError as err: + output.fprint( + errfile, + "Invalid test group id '{}'.\n{}" + .format(raw_id, err.pformat())) + continue + + if not group.exists(): + output.fprint( + errfile, + "Group '{}' does not exist.".format(raw_id)) + continue + + try: + test_paths.extend(group.tests()) + except TestGroupError as err: + output.fprint( + errfile, + "Invalid test group id '{}', could not get tests from group." + .format(raw_id)) return test_paths diff --git a/lib/pavilion/commands/__init__.py b/lib/pavilion/commands/__init__.py index 8b8e2240b..6d7667e40 100644 --- a/lib/pavilion/commands/__init__.py +++ b/lib/pavilion/commands/__init__.py @@ -22,6 +22,7 @@ 'clean': 'CleanCommand', 'config': 'ConfigCommand', 'graph': 'GraphCommand', + 'group': 'GroupCommand', 'list_cmd': 'ListCommand', 'log': 'LogCommand', 'ls': 'LSCommand', diff --git a/lib/pavilion/commands/clean.py b/lib/pavilion/commands/clean.py index c80e470c8..68750a186 100644 --- a/lib/pavilion/commands/clean.py +++ b/lib/pavilion/commands/clean.py @@ -94,8 +94,8 @@ def run(self, pav_cfg: PavConfig, args): builds_dir = working_dir / 'builds' # type: Path tests_dir = working_dir / 'test_runs' output.fprint(self.outfile, "Removing Builds ({})".format(working_dir), end=end) - rm_builds_count, msgs = clean.delete_builds(pav_cfg, builds_dir, tests_dir, - args.verbose) + rm_builds_count, msgs = clean.delete_unused_builds(pav_cfg, builds_dir, tests_dir, + args.verbose) msgs.extend(clean.delete_lingering_build_files(pav_cfg, builds_dir, tests_dir, args.verbose)) if args.verbose: @@ -104,4 +104,13 @@ def run(self, pav_cfg: PavConfig, args): output.fprint(self.outfile, "Removed {} build(s).".format(rm_builds_count), color=output.GREEN, clear=True) + + deleted_groups, msgs = clean.clean_groups(pav_cfg) + if args.verbose: + for msg in msgs: + output.fprint(self.outfile, msg, color=output.YELLOW) + output.fprint(self.outfile, + "Removed {} test groups that became empty.".format(deleted_groups), + color=output.GREEN, clear=True) + return 0 diff --git a/lib/pavilion/commands/group.py b/lib/pavilion/commands/group.py new file mode 100644 index 000000000..dc4b04934 --- /dev/null +++ b/lib/pavilion/commands/group.py @@ -0,0 +1,322 @@ +"""Command for managing test groups.""" + +import errno +import fnmatch + +from pavilion import groups +from pavilion import config +from pavilion import output +from pavilion.output import fprint, draw_table +from pavilion.enums import Verbose +from pavilion.groups import TestGroup +from pavilion.errors import TestGroupError +from .base_classes import Command, sub_cmd + + +class GroupCommand(Command): + + REMOVE_ALIASES = ['rem', 'rm'] + MEMBER_ALIASES = ['mem'] + LIST_ALIASES = ['ls'] + + def __init__(self): + super().__init__( + name='group', + description="Manage groups of Pavilion test runs and test series. Groups " + "can even contain other groups. Group names are case insensitive.", + short_help="Manage pavilion test run groups.", + sub_commands=True, + ) + + self._parser = None + + def _setup_arguments(self, parser): + + self._parser = parser + + parser.add_argument( + '--verbosity', choices=(verb.name for verb in Verbose)) + + subparsers = parser.add_subparsers( + dest="sub_cmd", + help="Group sub-command") + + add_p = subparsers.add_parser( + 'add', + help="Add tests/series/groups to a group.", + description="Add the given test ids, series ids, or group names to the group, " + "creating it if it doesn't exist.", + ) + + add_p.add_argument( + 'group', + help="The group to add to.") + add_p.add_argument( + 'items', nargs='+', + help="Items to add to the group. These can be test run ID's " + "(IE '27' or 'restricted.27'), series ID's (IE 's33'), " + "or even group names. IE( 'my-group')") + + remove_p = subparsers.add_parser( + 'remove', + aliases=self.REMOVE_ALIASES, + help="Remove tests/series/groups from a group.", + description="Remove all given ID's (test/series/group) from the group.") + + remove_p.add_argument( + 'group', help="The group to remove items from.") + remove_p.add_argument( + 'items', nargs='+', + help="Test run, test series, and group ID's to remove, as per `pav group add`.") + + delete_p = subparsers.add_parser( + 'delete', + help="Delete the given group entirely.") + delete_p.add_argument( + 'group', help="The group to delete.") + + rename_p = subparsers.add_parser( + 'rename', + help="Rename a group.") + rename_p.add_argument( + 'group', help="The group to rename.") + rename_p.add_argument( + 'new_name', help="The new name for the group") + rename_p.add_argument( + '--no-redirect', action='store_true', default=False, + help="By default, groups that point to this group are redirected to the new name. " + "This disables that.") + + list_p = subparsers.add_parser( + 'list', + aliases=self.LIST_ALIASES, + help="List all groups.",) + list_p.add_argument('match', nargs='*', + help="Only show tests that match one of the given glob strings. " + "IE: 'mygroups_*'") + + member_p = subparsers.add_parser( + 'members', + aliases=self.MEMBER_ALIASES, + help="List all the tests, series, and groups under this group. Items " + "listed are those specifically attached to the group, and does " + "include those attached indirectly through series. To see all tests " + "in a group, use `pav status`.") + member_p.add_argument( + 'group', help="The group to list.") + member_p.add_argument( + '--recursive', '-r', action='store_true', default=False, + help="Recursively list members of child groups as well.") + member_p.add_argument( + '--tests', '-t', action='store_true', default=False, + help="Show tests, and disable the default of showing everything.") + member_p.add_argument( + '--series', '-s', action='store_true', default=False, + help="Show series, and disable the default of showing everything.") + member_p.add_argument( + '--groups', '-g', action='store_true', default=False, + help="Show groups, and disable the default of showing everything.") + + def run(self, pav_cfg, args): + """Run the selected sub command.""" + + return self._run_sub_command(pav_cfg, args) + + def _get_group(self, pav_cfg, group_name: str) -> TestGroup: + """Get the requested group, and print a standard error message on failure.""" + + try: + group = TestGroup(pav_cfg, group_name) + except TestGroupError as err: + fprint(self.errfile, "Error loading group '{}'", color=output.RED) + fprint(self.errfile, err.pformat()) + return None + + if not group.exists(): + fprint(self.errfile, + "Group '{}' does not exist.\n Looked here:" + .format(group_name), color=output.RED) + fprint(self.errfile, " " + group.path.as_posix()) + return None + + return group + + @sub_cmd() + def _add_cmd(self, pav_cfg, args): + """Add the given tests/series/groups to this group.""" + + try: + group = TestGroup(pav_cfg, args.group) + if not group.exists(): + group.create() + except TestGroupError as err: + fprint(self.errfile, "Error adding tests.", color=output.RED) + fprint(self.errfile, err.pformat()) + return 1 + + added, errors = group.add(args.items) + if errors: + fprint(self.errfile, "There were one or more errors when adding tests.", + color=output.RED) + for error in errors: + fprint(self.errfile, error.pformat(), '\n') + + existed = len(args.items) - len(added) - len(errors) + fprint(self.outfile, + "Added {} item{} to the group ({} already existed)." + .format(len(added), '' if len(added) == 1 else 's', existed)) + + if errors: + return 1 + else: + return 0 + + @sub_cmd(*REMOVE_ALIASES) + def _remove_cmd(self, pav_cfg, args): + """Remove the given tests/series/groups""" + + group = self._get_group(pav_cfg, args.group) + if group is None: + return 1 + + removed, errors = group.remove(args.items) + if errors: + fprint(self.errfile, "There were one or more errors when removing tests.", + color=output.RED) + for error in errors: + output.fprint(self.errfile, error.pformat(), '\n') + + fprint(self.outfile, + "Removed {} item{}." + .format(len(removed), '' if len(removed) == 1 else 's')) + + return 1 if errors else 0 + + @sub_cmd() + def _delete_cmd(self, pav_cfg, args): + """Delete the group entirely.""" + + group = self._get_group(pav_cfg, args.group) + if group is None: + return 1 + + members = [] + try: + members = group.members() + except TestGroupError as err: + fprint(self.errfile, + "Could not list group contents for some reason. " + "Successful deletion is unlikely.", + color=output.YELLOW) + + if members: + fprint(self.outfile, "To recreate this group, run the following:", color=output.CYAN) + + member_names = [mem['name'] for mem in members] + fprint(self.outfile, ' pav group add {} {}'.format(group.name, ' '.join(member_names))) + + try: + group.delete() + fprint(self.outfile, "Group '{}' deleted.".format(group.name)) + except TestGroupError as err: + fprint(self.errfile, + "Could not remove group '{}'" + .format(group.display_name), color=output.RED) + fprint(self.errfile, err.pformat()) + return 1 + + return 0 + + @sub_cmd() + def _list_cmd(self, pav_cfg, args): + """List all groups.""" + + groups_dir = pav_cfg.working_dir/TestGroup.GROUPS_DIR + + groups_info = [] + if groups_dir.exists(): + for group_dir in groups_dir.iterdir(): + name = group_dir.name + if args.match: + for match in args.match: + if fnmatch.fnmatch(match, name): + break + else: + continue + + group = TestGroup(pav_cfg, group_dir.name) + groups_info.append(group.info()) + + groups_info.sort(key=lambda v: v['created'], reverse=True) + + draw_table( + self.outfile, + fields = ['name', 'tests', 'series', 'groups', 'created'], + rows = groups_info, + field_info={ + 'created': {'transform': output.get_relative_timestamp} + }) + + @sub_cmd(*MEMBER_ALIASES) + def _members_cmd(self, pav_cfg, args): + """List the members of a group.""" + + group = self._get_group(pav_cfg, args.group) + if group is None: + return 1 + + if True not in (args.tests, args.series, args.groups): + show_tests = show_series = show_groups = True + else: + show_tests = args.tests + show_series = args.series + show_groups = args.groups + + try: + members = group.members(recursive=args.recursive) + except TestGroupError as err: + fprint(self.errfile, "Could not get members.", color=output.RED) + fprint(self.errfile, err.pformat()) + return 1 + + filtered_members = [] + for mem in members: + if show_tests and mem['itype'] == TestGroup.TEST_ITYPE: + filtered_members.append(mem) + elif show_series and mem['itype'] == TestGroup.SERIES_ITYPE: + filtered_members.append(mem) + elif show_groups and mem['itype'] == TestGroup.GROUP_ITYPE: + filtered_members.append(mem) + members = filtered_members + + fields = ['itype', 'id', 'name', 'created'] + + if args.recursive: + fields.insert(0, 'group') + + draw_table( + self.outfile, + rows=members, + fields=fields, + field_info={ + 'itype': {'title': 'type'}, + 'created': {'transform': output.get_relative_timestamp} + }) + + return 0 + + @sub_cmd() + def _rename_cmd(self, pav_cfg, args): + """Give a test group a new name.""" + + group = self._get_group(pav_cfg, args.group) + if group is None: + return 1 + + try: + group.rename(args.new_name, redirect_parents=not args.no_redirect) + except TestGroupError as err: + fprint(self.errfile, "Error renaming group.", color=output.RED) + fprint(self.errfile, err.pformat()) + + return 0 diff --git a/lib/pavilion/commands/run.py b/lib/pavilion/commands/run.py index 053df522b..d81870ee0 100644 --- a/lib/pavilion/commands/run.py +++ b/lib/pavilion/commands/run.py @@ -4,6 +4,7 @@ import sys from pavilion import cmd_utils +from pavilion import groups from pavilion import output from pavilion.enums import Verbose from pavilion.errors import TestSeriesError @@ -76,6 +77,9 @@ def _generic_arguments(parser): 'configs are resolved. They should take the form ' '\'key=value\', where key is the dot separated key name, ' 'and value is a json object. Example: `-c schedule.nodes=23`') + parser.add_argument( + '-g', '--group', action="store", type=str, + help="Add the created test series to the given group, creating it if necessary.") parser.add_argument( '-v', '--verbosity', choices=[verb.name for verb in Verbose], default=Verbose.DYNAMIC.name, @@ -151,8 +155,19 @@ def run(self, pav_cfg, args): verbosity=Verbose[args.verbosity], outfile=self.outfile) - output.fprint(self.errfile, "Created Test Series {}.".format(series_obj.name)) + if args.group: + try: + group = groups.TestGroup(pav_cfg, args.group) + group.add([series_obj]) + except groups.TestGroupError as err: + output.fprint(self.errfile, + "Could not add series to group '{}'".format(args.group), + color=output.RED) + output.fprint(self.errfile, err.pformat()) + return errno.EINVAL + else: + output.fprint(self.outfile, "Created Test Series {}.".format(series_obj.name)) series_obj.add_test_set_config( 'cmd_line', tests, diff --git a/lib/pavilion/commands/series.py b/lib/pavilion/commands/series.py index 85d351275..e896d8c23 100644 --- a/lib/pavilion/commands/series.py +++ b/lib/pavilion/commands/series.py @@ -9,6 +9,7 @@ from pavilion import config from pavilion import cmd_utils from pavilion import filters +from pavilion import groups from pavilion import output from pavilion import series from pavilion import series_config @@ -108,7 +109,9 @@ def _setup_arguments(self, parser): '-m', '--mode', action='append', dest='modes', default=[], help='Mode configurations to overlay on the host configuration for ' 'each test. These are overlayed in the order given.') - + run_p.add_argument( + '--group', '-g', action="store", type=str, + help="Add the created test series to the given group, creating it if necessary.") run_p.add_argument( '-V', '--skip-verify', action='store_true', default=False, help="By default we load all the relevant configs. This can take some " @@ -183,7 +186,6 @@ def _run_cmd(self, pav_cfg, args): modes=args.modes, overrides=args.overrides) except series_config.SeriesConfigError as err: - output.fprint(self.errfile, err.pformat(), color=output.RED) return errno.EINVAL @@ -198,7 +200,21 @@ def _run_cmd(self, pav_cfg, args): .format(args.series_name), err, color=output.RED) return errno.EINVAL - output.fprint(self.errfile, "Created Test Series {}.".format(series_obj.name)) + + if args.group: + try: + group = groups.TestGroup(pav_cfg, args.group) + group.add([series_obj]) + except groups.TestGroupError as err: + output.fprint(self.errfile, "Error adding series '{}' to group '{}'." + .format(series_obj.sid, group.name), color=output.RED) + output.fprint(self.errfile, err.pformat()) + return errno.EINVAL + output.fprint(self.errfile, + "Created Test Series '{}' and added it to test group '{}'." + .format(series_obj.name, group.name)) + else: + output.fprint(self.outfile, "Created Test Series {}.".format(series_obj.name)) # pav _series runs in background using subprocess try: @@ -210,6 +226,8 @@ def _run_cmd(self, pav_cfg, args): except TestSeriesWarning as err: output.fprint(self.errfile, err, color=output.YELLOW) + self.last_series = series_obj + output.fprint(self.outfile, "Started series {sid}.\n" "Run `pav series status {sid}` to view series status.\n" diff --git a/lib/pavilion/errors.py b/lib/pavilion/errors.py index bcfaead38..c7d160bc7 100644 --- a/lib/pavilion/errors.py +++ b/lib/pavilion/errors.py @@ -111,7 +111,8 @@ def pformat(self) -> str: else: if hasattr(next_exc, 'args') \ and isinstance(next_exc.args, (list, tuple)) \ - and next_exc.args: + and next_exc.args \ + and isinstance(next_exc.args[0], str): msg = next_exc.args[0] else: msg = str(next_exc) @@ -306,3 +307,6 @@ class SystemPluginError(PavilionError): class WGetError(RuntimeError): """Errors for the Wget subsystem.""" + +class TestGroupError(PavilionError): + """Errors thrown when managing test groups.""" diff --git a/lib/pavilion/groups.py b/lib/pavilion/groups.py index e469be95b..687579cfe 100644 --- a/lib/pavilion/groups.py +++ b/lib/pavilion/groups.py @@ -1,144 +1,545 @@ """Groups are a named collection of series and tests. They can be manipulated with the `pav group` command.""" +from pathlib import Path import re +import shutil +from typing import NewType, List, Tuple, Union, Dict from pavilion import config from pavilion.errors import TestGroupError -from pavilion.test_run import TestRun -from pavilion.series import TestSeries +from pavilion.series import TestSeries, list_series_tests, SeriesInfo +from pavilion.test_run import TestRun, TestAttributes +from pavilion.utils import is_int -class TestGroup: - """A named collection tests and series.""" - - GROUPS_DIR = 'groups' - TESTS_DIR = 'tests' - SERIES_DIR = 'series' - - group_name_re = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]+$') - - def __init__(self, pav_cfg: config.PavConfig, name: str): - - self.pav_cfg = pav_cfg - - if not group_name_re.match(name): - raise TestGroupError( - "Invalid group name '{}'\n" - "Group names must start with a letter, but can otherwise have any " - "combination of letters, numbers, underscores and dashes." - .format(name)) - if name[0] in ('s', 'S') and name[1:].isdigit(): - raise TestGroupError( - "Invalid group name '{}'\n" - "Group name looks too much like a series ID." - .format(name)) - - self.name = name.lower() - self.display_name = name.lower() - - self.path = self.pav_cfg.working_dir/GROUP_DIR/self.truename - - try: - self.path.mkdir(parents=True, exist_ok=True) - except OSError as err: - TestGroupError("Could not create group dir at '{}'" - .format(self.path), prior_error=err) - - for category in self.TESTS_DIR, self.SERIES_DIR, self.GROUP_DIR: - cat_dir = self.path/category - try: - cat_dir.mkdir(exist_ok=True) - except OSError as err: - raise TestGroupError("Could not create group category directory '{}'" - .format(cat_dir.as_posix()) - prior_error=err) - - def add_tests(self, tests: List[Union[TestRun, str, int]]) -> List[TestGroupError]: - """Add the tests to the group. Returns a list of errors encountered. - - :param tests: A list of tests. Can be TestRun objects, a full_id string, or an id integer. - """ - - tests_root = self.path/self.TESTS_DIR - warnings = [] - - for test in tests: - full_id, tpath = self._get_test_info(test) - - tpath = tests_root/test.full_id - - try: - tpath.symlink_to(test.path) - except OSError as err: - warnings.append(TestGroupError( - "Could not add test '{}' to group." - .format(test.full_id), prior_error=err)) - - return warnings - - def _get_test_info(self, test: Union[TestRun, str, int]) -> Tuple[str, Path]: - """Find the test full id and path from the given test information.""" - - if isinstance(test, TestRun): - if not test.path.exists(): - raise TestGroupError("Test '{}' does not exist.".format(test.full_id)) - return test.full_id, test.path - - if isinstance(test, str): - if '.' in test: - cfg_label, test_id = test.split('.', maxsplit=1) - else: - cfg_label = config.DEFAULT_CONFIG_LABEL - test_id = test +GroupMemberDescr = NewType('GroupMemberDescr', Union[TestRun, TestSeries, "TestGroup", str]) +FlexDescr = NewType('FlexDescr', Union[List[GroupMemberDescr], GroupMemberDescr]) - elif isinstance(test, int): - cfg_label = config.DEFAULT_CONFIG_LABEL - test_id = str(int) - if not test_id.isnumeric(): - raise TestGroupError( - "Invalid test id '{}' from test id '{}'.\n" - "Test id's must be a number, like 27." - .format(test_id, test)) - if cfg_label not in self.pav_cfg.configs: - raise TestGroupError( - "Invalid config label '{}' from test id '{}'.\n" - "No Pavilion configuration directory exists. Valid config " - "labels are:\n {}" - .format(cfg_label, test, - '\n'.join([' - {}'.format(lbl for lbl in self.pav_cfg.configs)]))) - - config = self.pav_cfg.configs[cfg_label] - path = config.working_dir/'test_runs'/test_id - - - def add_series(self, series: List[TestSeries]) -> List[TestGroupError]: - """Add the test series to the group. Returns a list of errors encountered.""" - - series_root = self.path/self.SERIES_DIR - warnings = [] - for ser in series: - spath = series_root/ser.sid - - try: - spath.symlink_to(ser.path) - except OSError as err: - warnings.append(TestGroupError( - "Could not add series '{}' to group." - .format(ser.sid), prior_error=err)) - - return warnings - - def add_groups(self, groups: List["TestGroup"]) -> List[TestGroupError]: - """Add the given groups to the this group.""" - - # Instead of making a symlink, we just touch a file with the group name. - # This prevents symlink loops. - - groups_root = self.path/self.GROUPS_DIR - warnings = [] - - for group in groups: +class TestGroup: + """A named collection tests and series.""" + GROUPS_DIR = 'groups' + TESTS_DIR = 'tests' + SERIES_DIR = 'series' + TEST_ITYPE = 'test' + SERIES_ITYPE = 'series' + GROUP_ITYPE = 'group' + group_name_re = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]+$') + + def __init__(self, pav_cfg: config.PavConfig, name: str): + + self.pav_cfg = pav_cfg + + self._check_name(name) + + self.name = name + + self.path = self.pav_cfg.working_dir/self.GROUPS_DIR/self.name + + if self.path.exists(): + self.created = True + else: + self.created = False + + def create(self): + """Actually create the group.""" + + try: + self.path.mkdir(parents=True, exist_ok=True) + except OSError as err: + TestGroupError("Could not create group dir at '{}'" + .format(self.path), prior_error=err) + + for category in self.TESTS_DIR, self.SERIES_DIR, self.GROUPS_DIR: + cat_dir = self.path/category + try: + cat_dir.mkdir(exist_ok=True) + except OSError as err: + raise TestGroupError("Could not create group category directory '{}'" + .format(cat_dir.as_posix()), + prior_error=err) + + self.created = True + + def exists(self) -> bool: + """Whether this group exists.""" + + return self.path.exists() + + def info(self) -> Dict: + """Return some basic group info. Number of tests, series, sub-groups, creation time.""" + + info = { + 'name': self.name, + 'created': self.path.stat().st_mtime, + } + for cat_type, cat_dir in ( + (self.TEST_ITYPE, self.TESTS_DIR), + (self.SERIES_ITYPE, self.SERIES_DIR), + (self.GROUP_ITYPE, self.GROUPS_DIR),): + cat_path = self.path/cat_dir + + if not cat_path.exists(): + info[cat_dir] = 0 + continue + info[cat_dir] = len(list(cat_path.iterdir())) + + return info + + def tests(self, seen_groups=None) -> List[Path]: + """Returns a list of paths to all tests in this group. Use with + cmd_utils.get_tests_by_paths to convert to real test objects. Bad links are ignored. + Groups are recursively examined (loops are allowed, but not followed). + """ + + seen_groups = seen_groups if seen_groups is not None else [] + seen_groups.append(self.name) + + tests = [] + + if not self.exists(): + return [] + + # Get all the tests directly added to the group. + try: + if (self.path/self.TESTS_DIR).exists(): + for test_dir in (self.path/self.TESTS_DIR).iterdir(): + try: + if not test_dir.exists(): + continue + except OSError as err: + raise TestGroupError( + "Error getting test '{}' from group '{}'" + .format(test_dir.name, self.name), + prior_error=err) + + tests.append(test_dir) + + except OSError as err: + raise TestGroupError("Error getting tests for group '{}'" + .format(self.name), prior_error=err) + + # Get all the tests from each series + try: + if (self.path/self.SERIES_DIR).exists(): + for series_dir in (self.path/self.SERIES_DIR).iterdir(): + try: + if not series_dir.exists(): + continue + except OSError as err: + raise TestGroupError( + "Error getting test series '{}' from group '{}'" + .format(series_dir.name, self.name), + prior_error=err) + + tests.extend(list_series_tests(self.pav_cfg, series_dir.name)) + + except OSError as err: + raise TestGroupError( + "Error getting series for group '{}'" + .format(self.name), prior_error=err) + + # Finally, recursively get the tests for each group that's under this group. + try: + if (self.path/self.GROUPS_DIR).exists(): + for group_file in (self.path/self.GROUPS_DIR).iterdir(): + group_name = group_file.name + sub_group = TestGroup(self.pav_cfg, group_name) + if group_name not in seen_groups: + tests.extend(sub_group.tests(seen_groups=seen_groups)) + + except OSError as err: + raise TestGroupError( + "Error getting sub groups for group '{}'" + .format(self.name), prior_error=err) + + return tests + + def add(self, items: FlexDescr) -> Tuple[List[str], List[TestGroupError]]: + """Add each of the given items to the group. Accepts TestRun, TestSeries, and TestGroup + objects, as well as just the test/series(sid)/group names as strings. + + :returns: A list of the item names added, and a list of errors + """ + + if not isinstance(items, (list, tuple)): + items = [items] + + if not self.created: + self.create() + + warnings = [] + added = [] + for item in items: + # Get the type of item we're dealing with, and where it will be put in the group. + try: + itype, item_path = self._get_member_info(item) + except TestGroupError as err: + warnings.append( + TestGroupError("Could not add unknown item to test group '{}': '{}' '{}'" + .format(self.name, type(item), item), prior_error=err)) + continue + + if item_path.exists(): + # Don't try to add items that are already in the group. + continue + + # Get a string a name for the item, and the path to the actual item. + try: + if itype == self.TEST_ITYPE: + iname, dest_path = self._get_test_info(item) + elif itype == self.SERIES_ITYPE: + iname, dest_path = self._get_series_info(item) + elif itype == self.GROUP_ITYPE: + if isinstance(item, TestGroup): + agroup = item + else: + agroup = TestGroup(self.pav_cfg, item) + + # Don't add a test group to itself. + if agroup.name == self.name: + continue + + if not agroup.exists(): + warnings.append( + TestGroupError("Group '{}' does not exist.".format(agroup.name))) + continue + + iname = agroup.name + + except TestGroupError as err: + warnings.append( + TestGroupError("Could not add to test group '{}'".format(self.name), + prior_error=err)) + continue + + try: + # For tests and series, symlink to their directories. + if itype in (self.TEST_ITYPE, self.SERIES_ITYPE): + item_path.symlink_to(dest_path) + # For groups, just touch a file of that name (prevents symlink loops). + else: + item_path.touch() + added.append((itype, iname)) + except OSError as err: + warnings.append( + TestGroupError("Could not add {} '{}' to test group '{}'" + .format(itype, iname, self.name), prior_error=err)) + continue + + return added, warnings + + def remove(self, items: FlexDescr) -> Tuple[List[str], List[TestGroupError]]: + """Remove all of the given items from the group. Returns a list of warnings.""" + + removed = [] + warnings = [] + + if not isinstance(items, list): + items = [items] + + for item in items: + if isinstance(item, int): + item = str(item) + + itype, rmpath = self._get_member_info(item) + + if not rmpath.exists(): + warnings.append( + TestGroupError("Given {} '{}' to remove, but it is not in group '{}'." + .format(itype, item, self.name))) + continue + + try: + rmpath.unlink() + removed.append((itype, rmpath.name)) + except OSError: + warnings.append( + TestGroupError("Could not remove {} '{}' from group '{}'." + .format(itype, item, self.name))) + continue + + return removed, warnings + + def members(self, recursive=False, seen_groups=None) -> List[Dict]: + """Return a list of dicts of member info, keys 'itype', 'name'.""" + + seen_groups = seen_groups if seen_groups is not None else [] + seen_groups.append(self.name) + + if not self.exists(): + return [] + + members = [] + + for itype, type_dir in ( + (self.TEST_ITYPE, self.TESTS_DIR), + (self.SERIES_ITYPE, self.SERIES_DIR), + (self.GROUP_ITYPE, self.GROUPS_DIR)): + + try: + for path in (self.path/type_dir).iterdir(): + abs_path = None + try: + if path.exists(): + abs_path = path.resolve() + except OSError: + pass + + members.append({ + 'group': self.name, + 'itype': itype, + 'path': abs_path, + 'id': path.name,}) + + if recursive and itype == self.GROUP_ITYPE and path.name not in seen_groups: + try: + subgroup = self.__class__(self.pav_cfg, path.name) + except TestGroupError: + continue + + members.extend(subgroup.members(recursive=True, seen_groups=seen_groups)) + + except OSError as err: + raise TestGroupError( + "Could not list {} for group '{}'".format(type_dir, self.name), + prior_error=err) + + for mem_info in members: + path = mem_info['path'] + if path is None: + continue + + if mem_info['itype'] == self.TEST_ITYPE: + test_attrs = TestAttributes(mem_info['path']) + mem_info['name'] = test_attrs.name + mem_info['created'] = test_attrs.created + elif mem_info['itype'] == self.SERIES_ITYPE: + series_info = SeriesInfo(self.pav_cfg, path) + mem_info['name'] = series_info.name + mem_info['created'] = series_info.created + else: # Groups + path = self.path.parents[1]/path.name + if path.exists(): + mem_info['created'] = path.stat().st_mtime + return members + + def member_tuples(self) -> List[Tuple[str,str]]: + """As per 'members', but return a list of (item_type, item_id) tuples.""" + + tups = [] + for item in self.members(): + tups.append((item['itype'], item['id'])) + return tups + + def clean(self) -> List[TestGroupError]: + """Remove all dead links and group files, then delete the group if it's empty. + Returns a list of errors/warnings.""" + + keepers = False + warnings = [] + + # Cleanup items for each item type (tests, series, groups) + for itype, type_dir in ( + (self.TEST_ITYPE, self.TESTS_DIR), + (self.SERIES_ITYPE, self.SERIES_DIR), + (self.GROUP_ITYPE, self.GROUPS_DIR)): + + try: + for item_path in (self.path/type_dir).iterdir(): + # Skip that items that still exist. + if itype == self.GROUP_ITYPE: + if (self.path.parent/item_path.name).exists(): + keepers = True + continue + elif item_path.exists(): + # Note - this tests both if the target of the symlink and the symlink + # itself exit. (Absent a race condition of some sort, the + # symlink will exist.) + keepers = False + continue + + try: + item_path.unlink() + except OSError as err: + warnings.append( + TestGroupError( + "Could not remove test '{}' from group '{}'." + .format(test_link.name, self.name), + prior_error=err)) + except OSError as err: + warnings.append( + TestGroupError( + "Could not cleanup {} for group '{}'" + .format(self.GROUPS_DIR, self.name), + prior_error=err)) + + if not keepers: + try: + self.delete() + except TestGroupError as err: + warnings.append(err) + + return warnings + + def delete(self): + """Delete this group.""" + + try: + # Symlinks are just removed, not followed. + shutil.rmtree(self.path.as_posix()) + except OSError as err: + raise TestGroupError( + "Could not delete group '{}'".format(self.name), + prior_error=err) + + self.created = False + + def rename(self, new_name, redirect_parents=True): + """Rename this group. + + :param redirect_parents: Search other test groups for inclusion of this group, + and point them at the new name. + """ + + self._check_name(new_name) + + new_path = self.path.parent/new_name + + if new_path.exists(): + raise TestGroupError("Renaming group '{}' to '{}' but a group already exists " + "under that name.".format(self.name, new_name)) + + try: + self.path.rename(new_path) + except OSError as err: + raise TestGroupError( + "Could not rename group '{}' to '{}'".format(self.name, new_name), + prior_error=err) + + if redirect_parents: + try: + for group_path in self.path.parent.iterdir(): + for sub_group in (group_path/self.GROUPS_DIR).iterdir(): + if sub_group.name == self.name: + new_sub_path = sub_group.parent/new_name + sub_group.rename(new_sub_path) + except OSError as err: + raise TestGroupError("Failed to redirect parents of group '{}' to the new name." + .format(self.name), prior_error=err) + + self.name = new_name + self.path = new_path + + def _check_name(self, name: str): + """Make sure the given test group name complies with the naming standard.""" + + if self.group_name_re.match(name) is None: + raise TestGroupError( + "Invalid group name '{}'\n" + "Group names must start with a letter, but can otherwise have any " + "combination of letters, numbers, underscores and dashes." + .format(name)) + if name[0] in ('s', 'S') and is_int(name[1:]): + raise TestGroupError( + "Invalid group name '{}'\n" + "Group name looks too much like a series ID." + .format(name)) + + def _get_test_info(self, test: Union[TestRun, str]) -> Tuple[str, Path]: + """Find the test full id and path from the given test information.""" + + if isinstance(test, TestRun): + if not test.path.exists(): + raise TestGroupError("Test '{}' does not exist.".format(test.full_id)) + return test.full_id, test.path + + if isinstance(test, str): + if '.' in test: + cfg_label, test_id = test.split('.', maxsplit=1) + else: + cfg_label = config.DEFAULT_CONFIG_LABEL + test_id = test + + elif isinstance(test, int): + cfg_label = config.DEFAULT_CONFIG_LABEL + test_id = str(int) + # We'll use this as our full_id too. + test = test_id + + if not is_int(test_id): + raise TestGroupError( + "Invalid test id '{}' from test id '{}'.\n" + "Test id's must be a number, like 27." + .format(test_id, test)) + if cfg_label not in self.pav_cfg.configs: + raise TestGroupError( + "Invalid config label '{}' from test id '{}'.\n" + "No such Pavilion configuration directory exists. Valid config " + "labels are:\n {}" + .format(cfg_label, test, + '\n'.join([' - {}'.format(lbl for lbl in self.pav_cfg.configs)]))) + + rel_cfg = self.pav_cfg.configs[cfg_label] + tpath = rel_cfg.working_dir/'test_runs'/test_id + + if not tpath.is_dir(): + raise TestGroupError( + "Could not add test '{}' to group, test directory could not be found.\n" + "Looked at '{}'".format(test, tpath)) + + return test, tpath + + def _get_series_info(self, series: Union[TestSeries, str]) -> Tuple[str, Path]: + """Get the sid and path for a series, given a flexible description.""" + + if isinstance(series, TestSeries): + if not series.path.exists(): + raise TestGroupError("Series '{}' at '{}' does not exist." + .format(series.sid, series.path)) + return series.sid, series.path + + series = str(series) + if series.startswith("s"): + series_id = series[1:] + sid = series + else: + sid = 's{}'.format(series) + + if not is_int(series_id): + raise TestGroupError("Invalid series id '{}', not numeric id." + .format(series)) + + series_dir = self.pav_cfg.working_dir/'series'/series_id + + if not series_dir.is_dir(): + raise TestGroupError("Series directory for sid '{}' does not exist.\n" + "Looked at '{}'".format(sid, series_dir)) + + return sid, series_dir + + def _get_member_info(self, item: GroupMemberDescr) -> Tuple[str, Path]: + """Figure out what type of item 'item' is, and return its type name and path in + the group.""" + + if isinstance(item, TestRun): + return self.TEST_ITYPE, self.path/self.TESTS_DIR/item.full_id + elif isinstance(item, TestSeries): + return self.SERIES_ITYPE, self.path/self.SERIES_DIR/item.sid + elif isinstance(item, self.__class__): + return self.GROUP_ITYPE, self.path/self.GROUPS_DIR/item.name + elif isinstance(item, str): + if is_int(item) or '.' in item: + # Looks like a test id + return self.TEST_ITYPE, self.path/self.TESTS_DIR/item + elif item[0] == 's' and is_int(item[1:]): + # Looks like a sid + return self.SERIES_ITYPE, self.path/self.SERIES_DIR/item + else: + # Anything can only be a group + return self.GROUP_ITYPE, self.path/self.GROUPS_DIR/item + else: + raise TestGroupError("Invalid group item '{}' given for removal.".format(item)) diff --git a/lib/pavilion/test_run/__init__.py b/lib/pavilion/test_run/__init__.py index ef3052bed..cc35af9b7 100644 --- a/lib/pavilion/test_run/__init__.py +++ b/lib/pavilion/test_run/__init__.py @@ -2,4 +2,4 @@ from .test_attrs import TestAttributes, test_run_attr_transform from .test_run import TestRun -from .utils import get_latest_tests, load_tests +from .utils import get_latest_tests, load_tests, id_pair_from_path diff --git a/lib/pavilion/test_run/test_attrs.py b/lib/pavilion/test_run/test_attrs.py index d5cdad2fa..7e7050fb2 100644 --- a/lib/pavilion/test_run/test_attrs.py +++ b/lib/pavilion/test_run/test_attrs.py @@ -63,7 +63,7 @@ def __init__(self, path: Path, load=True): :param load: Whether to autoload the attributes. """ - self.path = path + self.path = path.resolve() self._attrs = {'warnings': []} @@ -313,6 +313,8 @@ def result(self): @property def full_id(self): + """The test full id, which is the config label it was created under + and the test id. The default config label is omitted.""" # If the cfg label is actually something that exists, use it in the # test full_id. Otherwise give the test path. if self.cfg_label == DEFAULT_CONFIG_LABEL or self.cfg_label is None: @@ -340,6 +342,7 @@ def state(self) -> str: name='finished', doc="The end time for this test run.") id = basic_attr( + name='id', doc="The test run id (unique per working_dir at any given time).") name = basic_attr( diff --git a/lib/pavilion/test_run/utils.py b/lib/pavilion/test_run/utils.py index 6c58e0be6..8aad83e90 100644 --- a/lib/pavilion/test_run/utils.py +++ b/lib/pavilion/test_run/utils.py @@ -1,6 +1,7 @@ """Utility functions for test run objects.""" from concurrent.futures import ThreadPoolExecutor +from pathlib import Path from typing import List, TextIO from pavilion import dir_db, output @@ -36,6 +37,24 @@ def get_latest_tests(pav_cfg: PavConfig, limit): return [test_id for _, test_id in test_dir_list[-limit:]] +def id_pair_from_path(path: Path) -> ID_Pair: + """Generate a test id pair given a path to a test. + Raises TestRunError if there are problems, or if the test doesn't exist.""" + + try: + path.resolve() + except OSError as err: + raise TestRunError("Test does not exist at path '{}'".format(path.as_posix())) + + try: + test_id = int(path.name) + except ValueError as err: + raise TestRunError("Invalid test id '{}' for test at path '{}'" + .format(path.name, path.as_posix())) + + working_dir = path.parents[1] + return ID_Pair((working_dir, test_id)) + def _load_test(pav_cfg, id_pair: ID_Pair): """Load a test object from an ID_Pair.""" diff --git a/lib/pavilion/utils.py b/lib/pavilion/utils.py index 2d9e9977a..5f3375520 100644 --- a/lib/pavilion/utils.py +++ b/lib/pavilion/utils.py @@ -30,6 +30,21 @@ def glob_to_re(glob): return glob +def is_int(val: str): + """Return true if the given string value is an integer.""" + + # isdigit, isnumeric and similar accept all kinds of weird unicode, like roman numerals. + + # An empty string is not an int + if not val: + return False + + for char in val: + if char not in '0123456789': + return False + + return True + def str_bool(val): """Returns true if the string value is the string 'true' with allowances for capitalization.""" diff --git a/test/tests/group_tests.py b/test/tests/group_tests.py new file mode 100644 index 000000000..93915fd4e --- /dev/null +++ b/test/tests/group_tests.py @@ -0,0 +1,263 @@ +from pavilion import unittest +from pavilion import groups +from pavilion.errors import TestGroupError +from pavilion import series +from pavilion.series_config import generate_series_config +from pavilion import commands +from pavilion import arguments + +import shutil +import uuid + + +class TestGroupTests(unittest.PavTestCase): + + def _make_group_name(self): + """Make a random group name.""" + + _ = self + + return 'grp_' + uuid.uuid4().hex[:10] + + def _make_example(self): + """Make an example group, and a tuple of a test, series, and sub-group.""" + + tr1 = self._quick_test() + tr2 = self._quick_test() + tr3 = self._quick_test() + series_cfg = generate_series_config('group_add1') + series1 = series.TestSeries(self.pav_cfg, series_cfg) + series1._add_tests([tr2], 'bob') + sub_group = groups.TestGroup(self.pav_cfg, self._make_group_name()) + self.assertEqual(sub_group.add([tr3]), ([('test', tr3.full_id)], [])) + + group = groups.TestGroup(self.pav_cfg, self._make_group_name()) + + return group, (tr1, series1, sub_group) + + def assertGroupContentsEqual(self, test_group, items): + """Verify that the group's contents match the given items ((itype, name) tuples).""" + members = [] + for mem in test_group.members(): + members.append((mem['itype'], mem['id'])) + + item_tuples = [] + for item in items: + if isinstance(item, groups.TestGroup): + item_tuples.append(('group', item.name)) + elif isinstance(item, series.TestSeries): + item_tuples.append(('series', item.sid)) + else: + item_tuples.append(('test', item.full_id)) + + members.sort() + item_tuples.sort() + self.assertEqual(members, item_tuples) + + def test_group_init(self): + """Check that object initialization and basic status functions work.""" + + group = groups.TestGroup(self.pav_cfg, 'init_test_group') + + self.assertFalse(group.exists()) + group.create() + self.assertTrue(group.exists()) + + for bad_name in ('s123', '-as3', '327bb', 'a b'): + with self.assertRaisesRegex(TestGroupError, r'Invalid group name'): + group = groups.TestGroup(self.pav_cfg, bad_name) # Bad group name. + + def test_member_info(self): + """Check that member info gathering works the same if given an object or a string.""" + + group, (test, series1, sub_group) = self._make_example() + + for obj, str_rep in ( + (test, test.full_id), + (series1, series1.sid), + (sub_group, sub_group.name)): + + self.assertEqual(group._get_member_info(obj), group._get_member_info(str_rep)) + + def test_group_add(self): + """Test that adding items to groups works.""" + + group, items = self._make_example() + test, series1, sub_group = items + added, errors = group.add(items) + self.assertEqual(errors, []) + added_answer = [('test', test.full_id), + ('series', series1.sid), + ('group', sub_group.name)] + added2, errors = group.add(items) + self.assertEqual(errors, []) + self.assertEqual(added2, []) + + # This should also do nothing - self-references are simply skipped. + self.assertEqual(group.add([group]), ([] , [])) + + # Make sure the group actually has the added items + self.assertGroupContentsEqual(group, items) + + added, errors = group.add( + ('does_not_exist.123441234', 'test.77262346324', 's1987234123', 'no_such_group')) + self.assertEqual(added, []) + self.assertEqual(len(errors), 4) + + def test_group_remove(self): + """Check that removing items from a group works.""" + + group, items = self._make_example() + test, series1, sub_group = items + group.add(items) + + # Remove a single item, to make sure other items are preserved + removed, errors = group.remove([series1]) + self.assertEqual(errors, []) + self.assertEqual(removed, [('series', series1.sid)]) + self.assertGroupContentsEqual(group, [test, sub_group]) + + # Remove multiple items. + removed, errors = group.remove([test, sub_group]) + self.assertEqual(errors, []) + self.assertEqual(removed, [('test', test.full_id), ('group', sub_group.name)]) + self.assertGroupContentsEqual(group, []) + + removed, errors = group.remove(['nope', 'a.1', 'test.982349842', 's1234981234']) + self.assertEqual(removed, []) + self.assertEqual(len(errors), 4) + + def test_group_clean(self): + """Check that cleaning works as expected.""" + + group, items = self._make_example() + test, series1, sub_group = items + group.add(items) + + # Delete the test, + shutil.rmtree(test.path) + errors = group.clean() + self.assertGroupContentsEqual(group, [series1, sub_group]) + + shutil.rmtree(series1.path) + sub_group.delete() + errors = group.clean() + self.assertEqual(errors, []) + self.assertFalse(group.exists()) + + def test_group_rename(self): + """Check group renaming.""" + + group, items = self._make_example() + _, _, sub_group = items + group.add(items) + + old_name = sub_group.name + new_name = self._make_group_name() + sub_group.rename(new_name) + self.assertEqual(sub_group.name, new_name) + self.assertEqual(sub_group.path.name, new_name) + self.assertTrue(sub_group.exists()) + self.assertIn(('group', new_name), group.member_tuples()) + self.assertNotIn(('group', old_name), group.member_tuples()) + + new_name2 = self._make_group_name() + sub_group.rename(new_name2, redirect_parents=False) + self.assertEqual(sub_group.name, new_name2) + self.assertEqual(sub_group.path.name, new_name2) + self.assertTrue(sub_group.exists()) + # The group doesn't exist under the old renaming, and we didn't rename it. + self.assertIn(('group', new_name), group.member_tuples()) + self.assertNotIn(('group', new_name2), group.member_tuples()) + + def test_group_commands(self): + """Check the operation of various group command statements.""" + + group_cmd = commands.get_command('group') + run_cmd = commands.get_command('run') + series_cmd = commands.get_command('series') + + for cmd in group_cmd, run_cmd, series_cmd: + cmd.silence() + + group_name = self._make_group_name() + parser = arguments.get_parser() + # Start a series of tests two ways, each assigned to a group. + + run_args = parser.parse_args(['run', '-g', group_name, 'hello_world']) + series_args = parser.parse_args(['series', 'run', '-g', group_name, 'basic']) + + 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() + + group = groups.TestGroup(self.pav_cfg, group_name) + self.assertTrue(group.exists()) + self.assertEqual(len(group.members()), 2) + + # 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() + + # 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() + + add_items = [sub_group_name] + [test.full_id for test in run_cmd.last_tests] + rm_tests = add_items[1:3] + + def run_grp_cmd(args): + group_cmd.clear_output() + args = parser.parse_args(args) + ret = group_cmd.run(self.pav_cfg, args) + self.assertEqual(ret, 0) + + members = group.members() + # Add tests and a group via commands + run_grp_cmd(['group', 'add', group_name] + add_items) + self.assertEqual(len(group.tests()), 10) + + # Remove a couple tests + run_grp_cmd(['group', 'remove', group_name] + rm_tests) + self.assertEqual(len(group.tests()), 8) + + # Rename the subgroup + new_name1 = self._make_group_name() + new_name2 = self._make_group_name() + run_grp_cmd(['group', 'rename', sub_group_name, new_name1]) + self.assertEqual(len(group.tests()), 8) + run_grp_cmd(['group', 'rename', '--no-redirect', new_name1, new_name2]) + self.assertEqual(len(group.tests()), 5) + run_grp_cmd(['group', 'rename', new_name2, new_name1]) + self.assertEqual(len(group.tests()), 8) + + # Try all the list options + for rows, args in [ + (7, ['group', 'members', group_name]), + (4, ['group', 'members', '--tests', group_name]), + (5, ['group', 'members', '--series', group_name]), + (4, ['group', 'members', '--groups', group_name]), + (7, ['group', 'members', '--tests', '--series', '--groups', group_name]), + (8, ['group', 'members', '--recursive', group_name]), + ]: + run_grp_cmd(args) + out, err_out = group_cmd.clear_output() + self.assertEqual(len(out.split('\n')), rows, + msg="unexpected lines for {}:\n{}" + .format(args, out)) + + # List all groups + group_cmd.clear_output() + run_grp_cmd(['group', 'list', 'grp_*']) + out, err = group_cmd.clear_output() + self.assertEqual(err, '') + + + # Delete the renamed sub-group + run_grp_cmd(['group', 'delete', new_name1]) + self.assertEqual(len(group.tests()), 5) From 129e8b23dd88d4795df070b376d39ff62818da75 Mon Sep 17 00:00:00 2001 From: Paul Ferrell Date: Tue, 27 Jun 2023 21:33:30 -0600 Subject: [PATCH 3/4] Minor exception fix. --- lib/pavilion/groups.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pavilion/groups.py b/lib/pavilion/groups.py index 687579cfe..145f6f0df 100644 --- a/lib/pavilion/groups.py +++ b/lib/pavilion/groups.py @@ -50,8 +50,8 @@ def create(self): try: self.path.mkdir(parents=True, exist_ok=True) except OSError as err: - TestGroupError("Could not create group dir at '{}'" - .format(self.path), prior_error=err) + raise TestGroupError("Could not create group dir at '{}'" + .format(self.path), prior_error=err) for category in self.TESTS_DIR, self.SERIES_DIR, self.GROUPS_DIR: cat_dir = self.path/category From e80007edbb24259b2e2a12b55b214fbc01819a7b Mon Sep 17 00:00:00 2001 From: Paul Ferrell Date: Wed, 9 Aug 2023 14:54:43 -0600 Subject: [PATCH 4/4] Added the 'excluded' tests to groups. --- lib/pavilion/groups.py | 109 +++++++++++++++++++++++++++++++++++- lib/pavilion/series/info.py | 2 +- test/tests/group_tests.py | 38 +++++++++++-- 3 files changed, 142 insertions(+), 7 deletions(-) diff --git a/lib/pavilion/groups.py b/lib/pavilion/groups.py index 145f6f0df..f44faa1aa 100644 --- a/lib/pavilion/groups.py +++ b/lib/pavilion/groups.py @@ -5,6 +5,7 @@ import re import shutil from typing import NewType, List, Tuple, Union, Dict +import uuid from pavilion import config from pavilion.errors import TestGroupError @@ -22,10 +23,12 @@ class TestGroup: GROUPS_DIR = 'groups' TESTS_DIR = 'tests' SERIES_DIR = 'series' + EXCLUDED_DIR = 'excluded' TEST_ITYPE = 'test' SERIES_ITYPE = 'series' GROUP_ITYPE = 'group' + EXCL_ITYPE = 'test*' group_name_re = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]+$') @@ -53,7 +56,7 @@ def create(self): raise TestGroupError("Could not create group dir at '{}'" .format(self.path), prior_error=err) - for category in self.TESTS_DIR, self.SERIES_DIR, self.GROUPS_DIR: + for category in self.TESTS_DIR, self.SERIES_DIR, self.GROUPS_DIR, self.EXCLUDED_DIR: cat_dir = self.path/category try: cat_dir.mkdir(exist_ok=True) @@ -156,7 +159,15 @@ def tests(self, seen_groups=None) -> List[Path]: "Error getting sub groups for group '{}'" .format(self.name), prior_error=err) - return tests + # Filter out any excluded tests. + excluded_paths = list(self._excluded().values()) + tests = [test_path.resolve() for test_path in tests] + return [test_path for test_path in tests if test_path not in excluded_paths] + + def _has_test(self, test_path: Path) -> bool: + """Return True if the given test path is included anywhere in the group.""" + + return test_path in self.tests() def add(self, items: FlexDescr) -> Tuple[List[str], List[TestGroupError]]: """Add each of the given items to the group. Accepts TestRun, TestSeries, and TestGroup @@ -216,9 +227,24 @@ def add(self, items: FlexDescr) -> Tuple[List[str], List[TestGroupError]]: prior_error=err)) continue + if itype == self.TEST_ITYPE: + if iname in self._excluded(): + try: + self._remove_excluded(iname) + except TestGroupError as err: + warnings.append( + TestGroupError( + "Could not remove exclusion for test {}.".format(iname), + prior_error=err)) + + if self._has_test(dest_path): + added.append((self.EXCL_ITYPE, iname)) + continue + try: # For tests and series, symlink to their directories. if itype in (self.TEST_ITYPE, self.SERIES_ITYPE): + # Add the item, unless it just needed to be un-excluded. item_path.symlink_to(dest_path) # For groups, just touch a file of that name (prevents symlink loops). else: @@ -241,6 +267,8 @@ def remove(self, items: FlexDescr) -> Tuple[List[str], List[TestGroupError]]: if not isinstance(items, list): items = [items] + all_tests = None + for item in items: if isinstance(item, int): item = str(item) @@ -248,6 +276,25 @@ def remove(self, items: FlexDescr) -> Tuple[List[str], List[TestGroupError]]: itype, rmpath = self._get_member_info(item) if not rmpath.exists(): + if itype == self.TEST_ITYPE: + try: + t_full_id, t_path = self._get_test_info(rmpath.name) + except TestGroupError as err: + warnings.append( + TestGroupError( + "Could not exclude item {}.".format(item), + prior_error=err)) + continue + + # Check to see if this is in our list of tests at all. + if all_tests is None: + all_tests = self.tests() + + if t_path in all_tests: + self._add_excluded(t_full_id, t_path) + removed.append((self.EXCL_ITYPE, t_full_id)) + continue + warnings.append( TestGroupError("Given {} '{}' to remove, but it is not in group '{}'." .format(itype, item, self.name))) @@ -543,3 +590,61 @@ def _get_member_info(self, item: GroupMemberDescr) -> Tuple[str, Path]: return self.GROUP_ITYPE, self.path/self.GROUPS_DIR/item else: raise TestGroupError("Invalid group item '{}' given for removal.".format(item)) + + def _excluded(self) -> Dict[str, Path]: + """Excluded items are items that are in series or sub-groups that were deleted + from this group. + + Returns a dict of excluded test id's and the test directories they point to. + """ + + excluded = {} + try: + for test_path in (self.path/self.EXCLUDED_DIR).iterdir(): + full_id = test_path.name + test_path = test_path.resolve() + if test_path.exists(): + excluded[full_id] = test_path + except (OSError, FileNotFoundError): + pass + + return excluded + + def _add_excluded(self, full_id: str, test_path: Path): + """Add the given test path to the excluded directory.""" + + path = self.path/self.EXCLUDED_DIR/full_id + + try: + if not path.exists(): + path.symlink_to(test_path) + except (OSError, FileNotFoundError) as err: + raise TestGroupError( + "Could not create test exclusion record at {}".format(path), + prior_error=err) + + def _remove_excluded(self, full_id: str): + """Remove the test from the exclusion records.""" + + path = self.path/self.EXCLUDED_DIR/full_id + + try: + if path.exists(): + path.unlink() + except (OSError, FileNotFoundError) as err: + raise TestGroupError( + "Could not remove test exclusion record at {}".format(path), + prior_error=err) + + def _clean_excluded(self): + """Remove any dead links from the exclusion records.""" + + root_path = self.path/self.EXCLUDED_DIR + + for full_id, path in self._excluded().items(): + if not path.exists(): + ex_path = root_path/full_id + try: + ex_path.unlink() + except (OSError, FileNotFoundError) as err: + pass diff --git a/lib/pavilion/series/info.py b/lib/pavilion/series/info.py index 95506ada9..336327a33 100644 --- a/lib/pavilion/series/info.py +++ b/lib/pavilion/series/info.py @@ -215,7 +215,7 @@ def test_info(self, test_path) -> Union[TestAttributes, None]: try: test_info = TestAttributes(test_path) - except TestRunError: + except (OSError, TestRunError): test_info = None self._test_info[test_path] = test_info diff --git a/test/tests/group_tests.py b/test/tests/group_tests.py index 93915fd4e..401a7abac 100644 --- a/test/tests/group_tests.py +++ b/test/tests/group_tests.py @@ -1,10 +1,11 @@ -from pavilion import unittest +from pavilion import arguments +from pavilion import commands from pavilion import groups -from pavilion.errors import TestGroupError from pavilion import series +from pavilion import unittest +from pavilion.errors import TestGroupError from pavilion.series_config import generate_series_config -from pavilion import commands -from pavilion import arguments +from pavilion.test_run import TestRun import shutil import uuid @@ -127,6 +128,35 @@ def test_group_remove(self): self.assertEqual(removed, []) self.assertEqual(len(errors), 4) + def test_group_exclusions(self): + """Check that excluded tests are handled properly.""" + + group, (btest, series1, sub_group) = self._make_example() + group.add([btest, series1, sub_group]) + + # Remove the tests from the series and sub_group. + s_test = list(series1.tests.values())[0] + g_test = sub_group.tests()[0] + g_test = g_test.resolve() + g_test = TestRun.load(self.pav_cfg, g_test.parents[1], int(g_test.name)) + + removed, warnings = group.remove([g_test, s_test]) + self.assertEqual(warnings, []) + removed.sort() + answer = sorted([(group.EXCL_ITYPE, s_test.full_id), + (group.EXCL_ITYPE, g_test.full_id)]) + self.assertEqual(removed, answer) + self.assertEqual(group._excluded(), {s_test.full_id: s_test.path, + g_test.full_id: g_test.path}) + self.assertEqual(group.tests(), [btest.path]) + + group.remove([sub_group.name]) + + added, warnings = group.add([s_test, g_test]) + self.assertEqual(sorted(added), [('test', g_test.full_id), + ('test*', s_test.full_id)]) + self.assertEqual(warnings, []) + def test_group_clean(self): """Check that cleaning works as expected."""