diff --git a/lib/pavilion/arguments.py b/lib/pavilion/arguments.py index 40890ebc5..072815324 100644 --- a/lib/pavilion/arguments.py +++ b/lib/pavilion/arguments.py @@ -79,6 +79,10 @@ def get_parser(): '--profile-count', default=PROFILE_COUNT_DEFAULT, action='store', type=int, help="Number of rows in the profile table.") + parser.add_argument( + '--show-tracebacks', dest='show_tracebacks', action='store_true', + help="Display full traceback when printing error messages.") + _PAV_PARSER = parser _PAV_SUB_PARSER = parser.add_subparsers(dest='command_name') diff --git a/lib/pavilion/clean.py b/lib/pavilion/clean.py index a23bd46d2..c27a575b7 100644 --- a/lib/pavilion/clean.py +++ b/lib/pavilion/clean.py @@ -8,6 +8,7 @@ from pavilion import groups from pavilion import lockfile from pavilion import utils +from pavilion import config from pavilion.builder import TestBuilder from pavilion.test_run import test_run_attr_transform, TestAttributes @@ -74,7 +75,7 @@ def delete_unused_builds(pav_cfg, builds_dir: Path, tests_dir: Path, verbose: bo return count, msgs -def clean_groups(pav_cfg) -> Tuple[int, List[str]]: +def clean_groups(pav_cfg: config.PavConfig) -> 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.""" diff --git a/lib/pavilion/cmd_utils.py b/lib/pavilion/cmd_utils.py index 7376446f2..b6dcdfed7 100644 --- a/lib/pavilion/cmd_utils.py +++ b/lib/pavilion/cmd_utils.py @@ -8,7 +8,7 @@ import sys import time from pathlib import Path -from typing import List, TextIO, Union, Iterator +from typing import List, TextIO, Union, Optional, Iterator from collections import defaultdict from pavilion import config @@ -321,7 +321,8 @@ def get_collection_path(pav_cfg, collection) -> Union[Path, None]: return None -def test_list_to_paths(pav_cfg, req_tests, errfile=None) -> List[Path]: +def test_list_to_paths(pav_cfg: config.PavConfig, req_tests: List[str], + errfile: Optional[Path] = None) -> List[Path]: """Given a list of raw test id's and series id's, return a list of paths to those tests. The keyword 'last' may also be given to get the last series run by @@ -338,7 +339,6 @@ def test_list_to_paths(pav_cfg, req_tests, errfile=None) -> List[Path]: 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: @@ -359,10 +359,12 @@ def test_list_to_paths(pav_cfg, req_tests, errfile=None) -> List[Path]: test_path = test_wd/TestRun.RUN_DIR/str(_id) test_paths.append(test_path) + if not test_path.exists(): 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: diff --git a/lib/pavilion/commands/group.py b/lib/pavilion/commands/group.py index dc4b04934..2f6581e63 100644 --- a/lib/pavilion/commands/group.py +++ b/lib/pavilion/commands/group.py @@ -2,6 +2,7 @@ import errno import fnmatch +from typing import Optional from pavilion import groups from pavilion import config @@ -122,7 +123,7 @@ def run(self, pav_cfg, args): return self._run_sub_command(pav_cfg, args) - def _get_group(self, pav_cfg, group_name: str) -> TestGroup: + def _get_group(self, pav_cfg: config.PavConfig, group_name: str) -> Optional[TestGroup]: """Get the requested group, and print a standard error message on failure.""" try: @@ -130,6 +131,7 @@ def _get_group(self, pav_cfg, group_name: str) -> TestGroup: except TestGroupError as err: fprint(self.errfile, "Error loading group '{}'", color=output.RED) fprint(self.errfile, err.pformat()) + return None if not group.exists(): @@ -137,6 +139,7 @@ def _get_group(self, pav_cfg, group_name: str) -> TestGroup: "Group '{}' does not exist.\n Looked here:" .format(group_name), color=output.RED) fprint(self.errfile, " " + group.path.as_posix()) + return None return group diff --git a/lib/pavilion/commands/series.py b/lib/pavilion/commands/series.py index 64e1bba5d..c7b4af45c 100644 --- a/lib/pavilion/commands/series.py +++ b/lib/pavilion/commands/series.py @@ -156,7 +156,7 @@ def _setup_arguments(self, parser): state_p.add_argument('series', default='last', nargs='?', help="The series to print status history for.") - def _find_series(self, pav_cfg, series_name): + def _find_series(self, pav_cfg: config.PavConfig, series_name: int): """Grab the series based on the series name, if one was given.""" if series_name == 'last': diff --git a/lib/pavilion/errors.py b/lib/pavilion/errors.py index 77442a9c2..286196cb1 100644 --- a/lib/pavilion/errors.py +++ b/lib/pavilion/errors.py @@ -5,11 +5,17 @@ import pprint import textwrap import shutil +import traceback + +from traceback import format_exception +from typing import List import lark import yc_yaml +from pavilion.micro import flatten + class PavilionError(RuntimeError): """Base class for all Pavilion errors.""" @@ -17,6 +23,9 @@ class PavilionError(RuntimeError): SPLIT_RE = re.compile(': *\n? *') TAB_LEVEL = ' ' + # Set traceback behavior for all instances + show_tracebacks = False + def __init__(self, msg, prior_error=None, data=None): """These take a new message and whatever prior error caused the problem. @@ -30,6 +39,7 @@ def __init__(self, msg, prior_error=None, data=None): self.data = data super().__init__(msg) + @property def msg(self): """Just return msg. This exists to be overridden in order to allow for @@ -46,12 +56,28 @@ def __str__(self): else: return self.msg + @staticmethod + def _wrap_lines(lines: List[str], width: int) -> List[str]: + """Given a list of lines, produce a new list of lines wrapped to the specified width.""" + + lines = map(lambda x: textwrap.wrap(x, width=width), lines) + + return list(flatten(lines)) + + def pformat(self) -> str: - """Specially format the exception for printing.""" + """Specially format the exception for printing. If traceback is True, return the full + traceback associated with the error. Otherwise, return a summary of the error.""" + + width = shutil.get_terminal_size((80, 80)).columns + + if PavilionError.show_tracebacks: + lines = format_exception(PavilionError, self, self.__traceback__) + + return "".join(lines) lines = [] next_exc = self.prior_error - width = shutil.get_terminal_size((80, 80)).columns tab_level = 0 for line in str(self.msg).split('\n'): lines.extend(textwrap.wrap(line, width=width)) diff --git a/lib/pavilion/main.py b/lib/pavilion/main.py index adb846899..feb0b7c73 100644 --- a/lib/pavilion/main.py +++ b/lib/pavilion/main.py @@ -7,6 +7,8 @@ import pavilion.commands import pavilion.errors + +from pavilion.errors import PavilionError from . import arguments from . import commands from . import config @@ -41,8 +43,7 @@ def main(): # Pavilion is compatible with python >= 3.4 if (sys.version_info[0] != SUPPORTED_MAJOR_VERSION or sys.version_info[1] < MIN_SUPPORTED_MINOR_VERSION): - output.fprint(sys.stderr, "Pavilion requires python 3.6 or higher.", color=output.RED) - sys.exit(-1) + raise PavilionError("Pavilion requires python 3.6 or higher.") # This has to be done before we initialize plugins parser = arguments.get_parser() @@ -51,8 +52,7 @@ def main(): try: pav_cfg = config.find_pavilion_config() except Exception as err: - output.fprint(sys.stderr, "Error getting config, exiting.", err, color=output.RED) - sys.exit(-1) + raise PavilionError("Error getting config, exiting.") from err # Setup all the loggers for Pavilion log_output = log_setup.setup_loggers(pav_cfg) @@ -61,8 +61,7 @@ def main(): try: plugins.initialize_plugins(pav_cfg) except pavilion.errors.PluginError as err: - output.fprint(sys.stderr, "Error initializing plugins.", err, color=output.RED) - sys.exit(-1) + raise PavilionError("Error initializing plugins.") from err # Partially parse the arguments. All we really care about is the subcommand. partial_args, _ = parser.parse_known_args() @@ -178,7 +177,14 @@ def profile_main(): if __name__ == '__main__': - if '--profile' in sys.argv: - profile_main() - else: - main() + if '--show-tracebacks' in sys.argv: + PavilionError.show_tracebacks = True + + try: + if '--profile' in sys.argv: + profile_main() + else: + main() + except PavilionError as err: + err.pformat() + exit(-1) diff --git a/lib/pavilion/series/series.py b/lib/pavilion/series/series.py index dd62e5af6..0266e1a35 100644 --- a/lib/pavilion/series/series.py +++ b/lib/pavilion/series/series.py @@ -506,7 +506,8 @@ def run(self, build_only: bool = False, rebuild: bool = False, # Completion will be set when looked for. - def _run_set(self, test_set: TestSet, build_only: bool, rebuild: bool, local_builds_only: bool): + def _run_set(self, test_set: TestSet, build_only: bool, rebuild: bool, + local_builds_only: bool): """Run all requested tests in the given test set.""" # Track which builds we've already marked as deprecated, when doing rebuilds. diff --git a/test/tests/errors_tests.py b/test/tests/errors_tests.py index a44dea761..e5290ea5d 100644 --- a/test/tests/errors_tests.py +++ b/test/tests/errors_tests.py @@ -7,48 +7,70 @@ class ErrorTests(unittest.PavTestCase): - """Test functionaility of Pavilion specific errors.""" + """Test functionaility of Pavilion specific errors.""" - def test_error_pickling(self): - """Check that all of the Pavilon errors pickle and unpickle correctly.""" + def test_error_pickling(self): + """Check that all of the Pavilon errors pickle and unpickle correctly.""" - prior_error = ValueError("hiya") + prior_error = ValueError("hiya") - base_args = (["foo"], ) - base_kwargs = {'prior_error':prior_error, 'data': {"foo": "bar"}} + base_args = (["foo"], ) + base_kwargs = {'prior_error':prior_error, 'data': {"foo": "bar"}} - spec_args = { - 'VariableError': (('hello',), - {'var_set': 'var', 'var': 'foo', - 'index': 3, 'sub_var': 'ok', 'prior_error': prior_error}), - 'DeferredError': (('hello',), - {'var_set': 'var', 'var': 'foo', - 'index': 3, 'sub_var': 'ok', 'prior_error': prior_error}), - 'ParserValueError': ((Token('oh_no', 'terrible_things'), 'hello'), {}) - } + spec_args = { + 'VariableError': (('hello',), + {'var_set': 'var', 'var': 'foo', + 'index': 3, 'sub_var': 'ok', 'prior_error': prior_error}), + 'DeferredError': (('hello',), + {'var_set': 'var', 'var': 'foo', + 'index': 3, 'sub_var': 'ok', 'prior_error': prior_error}), + 'ParserValueError': ((Token('oh_no', 'terrible_things'), 'hello'), {}) + } - base_attrs = dir(errors.PavilionError("foo")) + base_attrs = dir(errors.PavilionError("foo")) - exc_classes = [] - for name in dir(errors): - obj = getattr(errors, name) - if (type(obj) == type(errors.PavilionError) - and issubclass(obj, errors.PavilionError)): - exc_classes.append(obj) + exc_classes = [] + for name in dir(errors): + obj = getattr(errors, name) + if (type(obj) == type(errors.PavilionError) + and issubclass(obj, errors.PavilionError)): + exc_classes.append(obj) - for exc_class in exc_classes: - exc_name = exc_class.__name__ + for exc_class in exc_classes: + exc_name = exc_class.__name__ - args, kwargs = spec_args.get(exc_name, (base_args, base_kwargs)) + args, kwargs = spec_args.get(exc_name, (base_args, base_kwargs)) - inst = exc_class(*args, **kwargs) + inst = exc_class(*args, **kwargs) - p_str = pickle.dumps(inst) + p_str = pickle.dumps(inst) - try: - new_inst = pickle.loads(p_str) - except TypeError: - self.fail("Failed to reconstitute exception '{}'".format(exc_name)) + try: + new_inst = pickle.loads(p_str) + except TypeError: + self.fail("Failed to reconstitute exception '{}'".format(exc_name)) - self.assertEqual(inst, new_inst) + self.assertEqual(inst, new_inst) + + def test_pformat(self): + """Test that pformat formats errors as expected, including when Pavilion + is set to show full tracebacks for errors.""" + + try: + try: + raise RuntimeError("Raised a RuntimeError as a test") + except RuntimeError as err: + raise errors.PavilionError("Match this") from err + except errors.PavilionError as err: + self.assertEqual(err.pformat(), "Match this") + + errors.PavilionError.show_tracebacks = True + + try: + try: + raise RuntimeError("Raised a RuntimeError as a test") + except RuntimeError as err: + raise errors.PavilionError("Match this") from err + except errors.PavilionError as err: + self.assertTrue(err.pformat().startswith("Traceback"))