From ca62d4f55bc6f0ff59f67a9f4ed62b40f9c5bdd7 Mon Sep 17 00:00:00 2001 From: Sachin Saharan Date: Wed, 4 Sep 2024 22:32:43 +0530 Subject: [PATCH 1/4] [New] saveframe utility This commit adds a new utility named 'saveframe' in pyflyby. This utility can be used to save information for debugging / reproducing an issue. Usage: If you have a piece of code that is currently failing due to an issue originating from another team's code, and you cannot share your private code as a reproducer, use this utility to save relevant information to a file. Share the generated file with the other team, enabling them to reproduce and diagnose the issue independently. This utility is provided with 2 interfaces: a script and a function. Script location: `pyflyby/bin/saveframe` Function location: `pyflyby.saveframe` (`from pyflyby import saveframe`) For more information on the `savefrane` script, checkout the help message using `pyflyby/bin/saveframe --help` For more information on the `saveframe` function, checkout the doc of `pyflyby.saveframe`. --- bin/saveframe | 265 +++++++++ etc/pyflyby/common.py | 2 +- lib/python/pyflyby/__init__.py | 1 + lib/python/pyflyby/_saveframe.py | 978 +++++++++++++++++++++++++++++++ tests/test_saveframe.py | 822 ++++++++++++++++++++++++++ 5 files changed, 2067 insertions(+), 1 deletion(-) create mode 100755 bin/saveframe create mode 100644 lib/python/pyflyby/_saveframe.py create mode 100644 tests/test_saveframe.py diff --git a/bin/saveframe b/bin/saveframe new file mode 100755 index 00000000..73eb7133 --- /dev/null +++ b/bin/saveframe @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +Utility to save information for debugging / reproducing an issue. + +Usage: +If you have a script or command that is currently failing due to an issue +originating from another team's code, and you cannot share your private code as +a reproducer, use this utility to save relevant information to a file (e.g., +error frames specific to the other team's codebase). Share the generated file +with the other team, enabling them to reproduce and diagnose the issue +independently. + +Information saved in the file: +This utility captures and saves error stack frames to a file. It includes the +values of local variables from each stack frame, as well as metadata about each +frame and the exception raised by the user's script or command. Following is the +sample structure of the info saved in the file: + +{ + # 5th frame from the bottom + 5: { + 'frame_index': 5, + 'filename': '/path/to/file.py', + 'lineno': 3423, + 'function_name': 'func1', + 'function_qualname': 'FooClass.func1', + 'function_object': , + 'module_name': '' + 'frame_identifier': '/path/to/file.py,3423,func1', + 'code': '... python code line ...' + 'variables': {'local_variable1': , 'local_variable2': , ...} + }, + # 17th frame from the bottom + 17: { + 'frame_index': 17, + ... + }, + ... + 'exception_full_string': f'{exc.__class.__name__}: {exc}' + 'exception_object': exc, + 'exception_string': str(exc), + 'exception_class_name': exc.__class__.__name__, + 'exception_class_qualname': exc.__class__.__qualname__, + 'traceback': '(multiline traceback) +} + +NOTE: + - The above data gets saved in the file in pickled form. + - In the above data, the key of each frame's entry is the index of that frame + from the bottom of the error stack trace. So the first frame from the bottom + (the error frame) has index 1, and so on. + - 'variables' key in each frame's entry stores the local variables of that frame. + - The 'exception_object' key stores the actual exception object but without + the __traceback__ info (for security reasons). + +Example Usage: + + $ saveframe + $ saveframe --filename=/path/to/file + $ saveframe --frames=frames_to_save + $ saveframe --variables=local_variables_to_include + $ saveframe --exclude_variables=local_variables_to_exclude + +For interactive use cases, checkout pyflyby.saveframe function. +""" +from __future__ import annotations + +# Save an unspoiled copy of globals for running the user program. +globals_cpy = globals().copy() + +import argparse +import os +import sys + +from pyflyby._saveframe import (_SAVEFRAME_LOGGER, + _save_frames_and_exception_info_to_file, + _validate_saveframe_arguments) + + +def getargs(): + """ + Parse the command-line arguments. + """ + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, + description=__doc__, + prog=os.path.basename(sys.argv[0])) + parser.add_argument( + "--filename", default=None, + help="File path in which to save the frame information. If this file " + "already exists, it will be overwritten; otherwise, a new file will " + "be created with permission mode '0o644'\nDefault behavior: " + "If --filename is not passed, the info gets saved in the " + "'saveframe.pkl' file in the current working directory." + ) + parser.add_argument( + "--frames", default=None, + help="Error stack frames to save. A single frame follows the format " + "'filename:line_no:function_name', where:\n" + " - filename: The file path or a regex pattern matching the file " + "path (displayed in the stack trace) of that error frame.\n" + " - line_no (Optional): The code line number (displayed in the " + "stack trace) of that error frame.\n" + " - function_name (Optional): The function name (displayed in " + "the stack trace) of that error frame.\n\n" + "Partial frames are also supported where line_no and/or function_name " + "can be omitted:\n" + " - filename:: -> Includes all the frames that matches the filename\n" + " - filename:line_no: -> Include all the frames that matches " + "specific line in any function in the filename\n" + " - filename::function_name -> Include all the frames that matches " + "any line in the specific function in the filename\n\n" + "Following formats are supported to pass the frames:\n\n" + "1. Single frame:\n" + " --frames=frame\n" + " Example: --frames=/path/to/file.py:24:some_func\n" + " Includes only the specified frame.\n\n" + "2. Multiple frames:\n" + " --frames=frame1,frame2,...\n" + " Example: --frames=/dir/foo.py:45:,.*/dir2/bar.py:89:caller\n" + " Includes all specified frames.\n\n" + "3. Range of frames:\n" + " --frames=first_frame..last_frame\n" + " Example: --frames=/dir/foo.py:45:get_foo../dir3/blah.py:23:myfunc\n" + " Includes all the frames from first_frame to last_frame (both inclusive).\n\n" + "4. Range from first_frame to bottom:\n" + " --frames=first_frame..\n" + " Example: --frames=/dir/foo.py:45:get_foo..\n" + " Includes all the frames from first_frame to the bottom of the stack trace.\n\n" + "5. Number of Frames from Bottom:\n" + " --frames=num\n" + " Example: --frames=5\n" + " Includes the last 'num' frames from the bottom of the stack trace.\n\n" + "Default behavior: If --frames is not passed, the first frame from " + "the bottom is saved." + ) + parser.add_argument( + "--variables", default=None, + help="Local variables to include in each frame. Allowed format:\n" + "--variables=var1,var2,var3...\nExample: --variables=foo,bar\n\n" + "Default behavior: If --variables is not passed, save all the local " + "variables of the included frames." + ) + parser.add_argument( + "--exclude_variables", default=None, + help="Local variables to exclude from each frame. Allowed format:\n" + "--exclude_variables=var1,var2,var3...\nExample: " + "--exclude_variables=foo,bar\n\n" + "Default behavior: If --exclude_variables is not passed, save all " + "the local variables of the included frames as per --variables." + ) + parser.add_argument( + "command", default=argparse.SUPPRESS, nargs=argparse.REMAINDER, + help="User's script / command to execute.") + args = parser.parse_args() + return args + + +def which(program): + """ + Find the complete path of the ``program``. + + :param program: + Program for which to find the complete path. + :return: + Complete path of the program. + """ + if os.access(program, os.R_OK): + return program + + fpath, fname = os.path.split(program) + if fpath: + if os.access(fpath, os.R_OK): + return program + else: + for path in os.environ["PATH"].split(os.pathsep): + exe_file = os.path.join(path, program) + if os.access(exe_file, os.X_OK): + return exe_file + + return None + + +def execfile(filepath, globals): + """ + Execute the script stored in ``filepath``. + + :param filepath: + Path of the script to execute. + :param globals: + globals context to use while executing ``filepath``. + """ + globals.update({ + "__file__": filepath, + "__name__": "__main__", + }) + with open(filepath, 'rb') as file: + exec(compile(file.read(), filepath, 'exec'), globals) + + +def run_program(command): + """ + Run a program. + + :param command: + List containing the command to run. + """ + if len(command) == 0: + raise SystemExit("Error: Please pass a valid script / command to run!") + if command[0] == '-c': + if len(command) == 1: + raise SystemExit("Error: Please pass a valid script / command to run!") + # Set sys.argv. Mimic regular python -c by dropping the code but + # keeping the rest. + sys.argv = ['-c'] + command[2:] + globals_cpy['__file__'] = None + # Evaluate the command line code. + code = compile(command[1], "", "exec") + eval(code) + else: + prog = which(command[0]) + if not prog: + raise SystemExit(f"Error: Can't find the script / command: {command[0]!r}") + + # Set sys.argv to mimic the command execution. + sys.argv = command + sys.path.insert(0, os.path.dirname(os.path.realpath(prog))) + execfile(prog, globals_cpy) + + +def main(): + """ + Main body of the script. + """ + args = getargs() + # Validate the arguments. + filename, frames, variables, exclude_variables = _validate_saveframe_arguments( + filename=args.filename, frames=args.frames, variables=args.variables, + exclude_variables=args.exclude_variables, utility='script') + command = args.command + command_string = ' '.join(command) + + if len(command) == 0: + raise SystemExit("Error: Please pass a valid script / command to run!") + if (command[0] in ['python', 'python3'] or command[0].endswith('/python') + or command[0].endswith('/python3')): + del command[0] + + # Run the user script / command. + try: + _SAVEFRAME_LOGGER.info(f"Executing the program: {command_string!a}") + run_program(command) + except Exception as err: + _SAVEFRAME_LOGGER.info( + f"Saving frames and metadata info for the exception: {err!a}") + # Save the frames and metadata info to the file. + _save_frames_and_exception_info_to_file( + filename=filename, frames=frames, variables=variables, + exclude_variables=exclude_variables, exception_obj=err) + else: + raise SystemExit( + f"Error: No exception is raised by the program: {command_string!a}") + + +if __name__ == '__main__': + main() diff --git a/etc/pyflyby/common.py b/etc/pyflyby/common.py index e4f45310..b9368806 100644 --- a/etc/pyflyby/common.py +++ b/etc/pyflyby/common.py @@ -11,7 +11,7 @@ import pexpect import pstats import pyflyby -from pyflyby import xreload +from pyflyby import saveframe, xreload import pylab import pyodbc import pysvn diff --git a/lib/python/pyflyby/__init__.py b/lib/python/pyflyby/__init__.py index b31abf4d..8dff1a6a 100644 --- a/lib/python/pyflyby/__init__.py +++ b/lib/python/pyflyby/__init__.py @@ -32,6 +32,7 @@ from pyflyby._livepatch import livepatch, xreload from pyflyby._log import logger from pyflyby._parse import PythonBlock, PythonStatement +from pyflyby._saveframe import saveframe from pyflyby._version import __version__ # Deprecated: diff --git a/lib/python/pyflyby/_saveframe.py b/lib/python/pyflyby/_saveframe.py new file mode 100644 index 00000000..7a3c64bb --- /dev/null +++ b/lib/python/pyflyby/_saveframe.py @@ -0,0 +1,978 @@ +""" +pyflyby/_saveframe.py + +Provides a utility to save the info for debugging / reproducing any issue. +Checkout the doc of the `saveframe` function for more info. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import inspect +import keyword +import linecache +import logging +import os +import pickle +import re +import stat +import sys +import traceback + +""" +The protocol used while pickling the frame's data. +""" +PICKLE_PROTOCOL=5 + +""" +The permissions used to create the file where the data is stored by the +'saveframe' utility. +""" +FILE_PERMISSION = 0o644 + +""" +The default filename used for storing the data when the user does not explicitly +provide one. +""" +DEFAULT_FILENAME = 'saveframe.pkl' + +def _get_saveframe_logger(): + """ + Get the logger used for the saveframe utility. + """ + log_format = ( + "[%(asctime)s:%(msecs)03d pyflyby.saveframe:%(lineno)s " + "%(levelname)s] %(message)s") + log_datefmt = "%Y%m%d %H:%M:%S" + logger = logging.getLogger(__name__) + logger.setLevel(logging.INFO) + if not logger.handlers: + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.INFO) + formatter = logging.Formatter( + fmt=log_format, datefmt=log_datefmt) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + return logger + +_SAVEFRAME_LOGGER = _get_saveframe_logger() + + +@contextmanager +def _open_file(filename, mode): + """ + A context manager to open the ``filename`` with ``mode``. + This function ignores the ``umask`` while creating the file. + + :param filename: + The file to open. + :param mode: + Mode in which to open the file. + """ + old_umask = os.umask(0) + fd = None + file_obj = None + try: + fd = os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_PERMISSION) + file_obj = os.fdopen(fd, mode) + yield file_obj + finally: + if file_obj is not None: + file_obj.close() + elif fd is not None: + os.close(fd) + os.umask(old_umask) + + +def _get_exception_info(exception_obj): + """ + Get the metadata information for the ``exception_obj``. + + :param exception_obj: + The exception raised by the user's code. + :return: + A dict containing all the required information. It contains + 'exception_full_string', 'exception_object', 'exception_string', + 'exception_class_name', 'exception_class_qualname' and 'traceback'. + """ + exception_info = {} + exception_info['exception_full_string'] = ( + f'{exception_obj.__class__.__name__}: {exception_obj}') + exception_info['exception_object'] = exception_obj + exception_info['exception_string'] = str(exception_obj) + exception_info['exception_class_name'] = exception_obj.__class__.__name__ + exception_info['exception_class_qualname'] = exception_obj.__class__.__qualname__ + try: + exception_info['traceback'] = ( + traceback.format_exception( + type(exception_obj), exception_obj, exception_obj.__traceback__)) + except Exception as err: + _SAVEFRAME_LOGGER.warning( + f"Error while formatting the traceback. Error: {err!a}") + exception_info['traceback'] = "Traceback couldn't be formatted" + return exception_info + + +def _get_frame_repr(frame): + """ + Construct repr for the ``frame``. This is used in the info messages. + + :param frame: + The frame object. + :return: + The string f'File: {filename}, Line: {lineno}, Function: {function_qualname}' + """ + return (f"'File: {frame.f_code.co_filename}, Line: {frame.f_lineno}, " + f"Function: {frame.f_code.co_qualname}'") + + +def _get_frame_local_variables_data(frame, variables, exclude_variables): + """ + Get the local variables data of the ``frame``. + + :param frame: + The frame object + :param variables: + Local variables to be included. + :param exclude_variables: + Local variables to be excluded. + :return: + A dict containing the local variables data, with the key as the variable + name and the value as the pickled local variable value. + """ + # A dict to store the local variables to be saved. + local_variables_to_save = {} + all_local_variables = frame.f_locals + for variable in all_local_variables: + # Discard the variables that starts with '__' like '__eq__', etc., to + # keep the data clean. + if variable.startswith('__'): + continue + if variables and variable not in variables: + continue + if exclude_variables and variable in exclude_variables: + continue + try: + pickled_value = pickle.dumps( + all_local_variables[variable], protocol=PICKLE_PROTOCOL) + except Exception as err: + _SAVEFRAME_LOGGER.warning( + f"Cannot pickle variable: {variable!a} for frame: " + f"{_get_frame_repr(frame)}. Error: {err!a}. Skipping this " + f"variable and continuing.") + else: + local_variables_to_save[variable] = pickled_value + return local_variables_to_save + + +def _get_frame_function_object(frame): + """ + Get the function object of the frame. + + This helper does a best-effort attempt to find the function object using + locals and globals dict. + + :param frame: + The frame object. + :return: + The function object from which the ``frame`` is originating. + """ + func_name = frame.f_code.co_name + func_qualname = frame.f_code.co_qualname + info_msg = f"Can't get function object for frame: {_get_frame_repr(frame)}" + return_msg = "Function object not found" + # The function is most-likely either a local function or a class method. + if func_name != func_qualname: + prev_frame = frame.f_back + # Handle the local functions. + if "" in func_qualname: + if prev_frame is None: + _SAVEFRAME_LOGGER.info(info_msg) + return return_msg + # The function is present in the previous frame's (the parent) locals. + if func_name in prev_frame.f_locals: + return prev_frame.f_locals[func_name] + _SAVEFRAME_LOGGER.info(info_msg) + return return_msg + # Handle the class methods. + else: + try: + func_parent = func_qualname.split('.')[-2] + except IndexError: + _SAVEFRAME_LOGGER.info(info_msg) + return return_msg + # The parent is present in the globals, so extract the function object + # using getattr. + if func_parent in frame.f_globals: + func_parent_obj = frame.f_globals[func_parent] + if hasattr(func_parent_obj, func_name): + return getattr(func_parent_obj, func_name) + _SAVEFRAME_LOGGER.info(info_msg) + return return_msg + # The function is most-likely a global function. + else: + if func_name in frame.f_globals: + return frame.f_globals[func_name] + _SAVEFRAME_LOGGER.info(info_msg) + return return_msg + + +def _get_frame_module_name(frame): + """ + Get the module name of the ``frame``. + + :param frame: + The frame object. + :return: + The name of the module from which the ``frame`` is originating. + """ + try: + frame_module = inspect.getmodule(frame) + if frame_module is not None: + return frame_module.__name__ + _SAVEFRAME_LOGGER.info(f"No module found for the frame: " + f"{_get_frame_repr(frame)}") + return "Module name not found" + except Exception as err: + _SAVEFRAME_LOGGER.warning( + f"Module name couldn't be found for the frame: " + f"{_get_frame_repr(frame)}. Error: {err!a}") + return "Module name not found" + + +def _get_frame_code_line(frame): + """ + Get the code line of the ``frame``. + + :param frame: + The frame object. + :return: + The code line as returned by the `linecache` package. + """ + filename = frame.f_code.co_filename + lineno = frame.f_lineno + code_line = linecache.getline(filename, lineno).strip() + if code_line is None: + code_line = f"No code content found at {filename!a}: {lineno}" + _SAVEFRAME_LOGGER.info(code_line + f" for frame {_get_frame_repr(frame)}") + return code_line + + +def _get_frame_metadata(frame_idx, frame_obj): + """ + Get metadata for the frame ``frame_obj``. + + :param frame_idx: + Index of the frame ``frame_obj`` from the bottom of the stack trace. + :param frame_obj: + The frame object for which to get the metadata. + :return: + A dict containing all the required metadata. The metadata returned is: + 'frame_index', 'filename', 'lineno', 'function_name', 'function_qualname', + 'function_object', 'module_name', 'code' and 'frame_identifier'. + """ + # Mapping that stores all the frame's metadata. + frame_metadata = {} + frame_metadata['frame_index'] = frame_idx + frame_metadata['filename'] = frame_obj.f_code.co_filename + frame_metadata['lineno'] = frame_obj.f_lineno + frame_metadata['function_name'] = frame_obj.f_code.co_name + frame_metadata['function_qualname'] = frame_obj.f_code.co_qualname + frame_function_object = _get_frame_function_object(frame_obj) + try: + pickled_function = pickle.dumps(frame_function_object, protocol=PICKLE_PROTOCOL) + except Exception as err: + _SAVEFRAME_LOGGER.info( + f"Cannot pickle the function object for the frame: " + f"{_get_frame_repr(frame_obj)}. Error: {err!a}") + frame_metadata['function_object'] = "Function object not pickleable" + else: + frame_metadata['function_object'] = pickled_function + frame_metadata['module_name'] = _get_frame_module_name(frame_obj) + frame_metadata['code'] = _get_frame_code_line(frame_obj) + frame_metadata['frame_identifier'] = ( + f"{frame_metadata['filename']},{frame_metadata['lineno']}," + f"{frame_metadata['function_name']}") + return frame_metadata + +def _get_all_matching_frames(frame, all_frames): + """ + Get all the frames from ``all_frames`` that match the ``frame``. + + The matching is done based on the filename / file regex, the line number + and the function name. + + :param frame: + Frame for which to find all the matching frames. + :param all_frames: + A list of all the frame objects from the exception object. + :return: + A list of all the frames that match the ``frame``. Each item in the list + is a tuple of 2 elements, where the first element is the frame index + (starting from the bottom of the stack trace) and the second element is + the frame object. + """ + if frame == ['']: + # This is the case where the last frame is not passed in the range + # ('first_frame..'). Return the first frame from the bottom as the last + # frame. + return [(1, all_frames[0])] + all_matching_frames = [] + filename_regex, lineno, func_name = frame + for idx, frame_obj in enumerate(all_frames): + if re.search(filename_regex, frame_obj.f_code.co_filename) is None: + continue + if lineno and frame_obj.f_lineno != lineno: + continue + if (func_name and + func_name not in (frame_obj.f_code.co_name, frame_obj.f_code.co_qualname)): + continue + all_matching_frames.append((idx+1, frame_obj)) + return all_matching_frames + + +def _get_frames_to_save(frames, all_frames): + """ + Get the frames we want to save from ``all_frames`` as per ``frames``. + + :param frames: + Frames that user wants to save. This parameter stores the parsed frames + returned by `_validate_frames` function. + :param all_frames: + A list of all the frame objects from the exception object. + :return: + A list of filtered frames to save. Each item in the list is a tuple of 2 + elements, where the first element is the frame index (starting from the + bottom of the stack trace) and the second element is the frame object. + """ + frames, frame_type = frames + filtered_frames = [] + if frame_type is None: + # No frame passed by the user, return the first frame from the bottom + # of the stack trace. + return [(1, all_frames[0])] + elif frame_type == "num": + if len(all_frames) < frames: + _SAVEFRAME_LOGGER.info( + f"Number of frames to dump are {frames}, but there are only " + f"{len(all_frames)} frames in the error stack. So dumping " + f"all the frames.") + frames = len(all_frames) + return [(idx+1, all_frames[idx]) for idx in range(frames)] + elif frame_type == "list": + for frame in frames: + filtered_frames.extend(_get_all_matching_frames(frame, all_frames)) + elif frame_type == "range": + # Handle 'first_frame..last_frame' and 'first_frame..' formats. + # Find all the matching frames for the first_frame and last_frame. + first_matching_frames = _get_all_matching_frames(frames[0], all_frames) + if len(first_matching_frames) == 0: + raise ValueError(f"No frame in the traceback matched the frame: " + f"{':'.join(map(str, frames[0]))!a}") + last_matching_frames = _get_all_matching_frames(frames[1], all_frames) + if len(last_matching_frames) == 0: + raise ValueError(f"No frame in the traceback matched the frame: " + f"{':'.join(map(str, frames[1]))!a}") + # Take out the minimum and maximum indexes of the matching frames. + first_idxs = (first_matching_frames[0][0], first_matching_frames[-1][0]) + last_idxs = (last_matching_frames[0][0], last_matching_frames[-1][0]) + # Find the maximum absolute distance between the start and end matching + # frame indexes, and get all the frames in between that range. + distances = [ + (abs(first_idxs[0] - last_idxs[0]), (first_idxs[0], last_idxs[0])), + (abs(first_idxs[0] - last_idxs[1]), (first_idxs[0], last_idxs[1])), + (abs(first_idxs[1] - last_idxs[0]), (first_idxs[1], last_idxs[0])), + (abs(first_idxs[1] - last_idxs[1]), (first_idxs[1], last_idxs[1])), + ] + _, max_distance_pair = max(distances, key=lambda x: x[0]) + max_distance_pair = sorted(max_distance_pair) + for idx in range(max_distance_pair[0], max_distance_pair[1] + 1): + filtered_frames.append((idx, all_frames[idx-1])) + + # Only keep the unique frames and sort them using their index. + filtered_frames = sorted(filtered_frames, key=lambda f: f[0]) + seen_frames = set() + unique_filtered_frames = [] + # In chained exceptions, we can get the same frame at multiple indexes, so + # get the unique frames without considering the index. + for frame in filtered_frames: + if frame[1] not in seen_frames: + unique_filtered_frames.append(frame) + seen_frames.add(frame[1]) + return unique_filtered_frames + + +def _get_all_frames_from_exception_obj(exception_obj): + """ + Get all the frame objects from the exception object. It also handles chained + exceptions. + + :param exception_obj: + The exception raise by the user's code. + :return: + A list containing all the frame objects from the exception. The frames + are stored in bottom-to-top order, with the bottom frame (the error frame) + at index 0. + """ + current_exception = exception_obj + all_frames = [] + while current_exception: + traceback = current_exception.__traceback__ + current_tb_frames = [] + while traceback: + current_tb_frames.append(traceback.tb_frame) + traceback = traceback.tb_next + all_frames.extend(reversed(current_tb_frames)) + current_exception = (current_exception.__cause__ or + current_exception.__context__) + return all_frames + + +def _save_frames_and_exception_info_to_file( + filename, frames, variables, exclude_variables, exception_obj): + """ + Save the frames and exception information in the file ``filename``. + + The data structure that gets saved is a dictionary. It stores each frame + info in a separate entry with the key as the frame index (from the bottom of + the stack trace). It also stores some useful exception information. The data + gets dumped in the ``filename`` in pickled form. Following is the same + structure of the info saved: + { + # 5th frame from the bottom + 5: { + 'frame_index': 5, + 'filename': '/path/to/file.py', + 'lineno': 3423, + 'function_name': 'func1', + 'function_qualname': 'FooClass.func1', + 'function_object': , + 'module_name': '' + 'frame_identifier': '/path/to/file.py,3423,func1', + 'code': '... python code line ...' + 'variables': {'local_variable1': , 'local_variable2': , ...} + }, + # 17th frame from the bottom + 17: { + 'frame_index': 17, + ... + }, + ... + 'exception_full_string': f'{exc.__class.__name__}: {exc}' + 'exception_object': exc, + 'exception_string': str(exc), + 'exception_class_name': exc.__class__.__name__, + 'exception_class_qualname': exc.__class__.__qualname__, + 'traceback': '(multiline traceback) + } + + :param filename: + The file path in which to save the information. + :param frames: + The frames to save in the file. This parameter stores the parsed frames + returned by the `_validate_frames` function. + :param variables: + The local variables to include in each frame. + :param exclude_variables: + The local variables to exclude from each frame. + :param exception_obj: + The ``Exception`` raised by the user's code. This is used to extract all + the required info; the traceback, all the frame objects, etc. + """ + # Mapping that stores all the information to save. + frames_and_exception_info = {} + # Get the list of frame objects from the exception object. + all_frames = _get_all_frames_from_exception_obj(exception_obj) + # Take out the frame objects we want to save as per 'frames'. + frames_to_save = _get_frames_to_save(frames, all_frames) + _SAVEFRAME_LOGGER.info(f"Number of frames that'll be saved: {len(frames_to_save)}") + + for frame_idx, frame_obj in frames_to_save: + _SAVEFRAME_LOGGER.info( + f"Getting required info for the frame: {_get_frame_repr(frame_obj)}") + frames_and_exception_info[frame_idx] = _get_frame_metadata( + frame_idx, frame_obj) + frames_and_exception_info[frame_idx]['variables'] = ( + _get_frame_local_variables_data(frame_obj, variables, exclude_variables)) + + _SAVEFRAME_LOGGER.info("Getting exception metadata info.") + frames_and_exception_info.update(_get_exception_info(exception_obj)) + _SAVEFRAME_LOGGER.info(f"Saving the complete data in the file: {filename!a}") + with _open_file(filename, 'wb') as f: + pickle.dump(frames_and_exception_info, f, protocol=PICKLE_PROTOCOL) + _SAVEFRAME_LOGGER.info("Done!!") + + +def _is_dir_and_ancestors_world_traversable(directory): + """ + Is the ``directory`` and all its ancestors world traversable. + + For world traversability we check if the execute bit is set for the + owner, group and others. + + :param directory: + The directory to check. + :return: + `True` if ``directory`` and its ancestors are world traversable, else + `False`. + """ + required_mode = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + dir_permission_mode = os.stat(directory).st_mode & 0o777 + if (dir_permission_mode & required_mode) != required_mode: + return False + return directory == "/" or _is_dir_and_ancestors_world_traversable( + os.path.dirname(directory)) + + +def _validate_filename(filename, utility): + """ + Validate the value of ``filename``. + + :param filename: + The file path to validate. + :param utility: + Indicates whether this helper is invoked by the ``pyflyby.saveframe`` function + or the ``pyflyby/bin/saveframe`` script. See `_validate_saveframe_arguments` + for more info. + :return: + The file path post validation. If ``filename`` is None, a default file + named `DEFAULT_FILENAME` in the current working directory is returned. + """ + if filename is None: + filename = os.path.abspath(DEFAULT_FILENAME) + _SAVEFRAME_LOGGER.info( + f"Filename is not passed explicitly using the " + f"{'`filename` parameter' if utility == 'function' else '--filename argument'}. " + f"The frame info will be saved in the file: {filename!a}.") + _SAVEFRAME_LOGGER.info(f"Validating filename: {filename!a}") + # Resolve any symlinks. + filename = os.path.realpath(filename) + if os.path.islink(filename): + raise ValueError(f"Cyclic link exists in the file: {filename!a}") + if os.path.isdir(filename): + raise ValueError(f"{filename!a} is an already existing directory. Please " + f"pass a different filename.") + if os.path.exists(filename): + _SAVEFRAME_LOGGER.info( + f"File {filename!a} already exists. This run will " + f"overwrite the file.") + parent_dir = os.path.dirname(filename) + # Check if the parent directory and the ancestors are world traversable. + # Log a warning if not. Raise an error if the parent or any ancestor + # directory doesn't exist. + is_parent_and_ancestors_world_traversable = True + try: + is_parent_and_ancestors_world_traversable = ( + _is_dir_and_ancestors_world_traversable(directory=parent_dir)) + except PermissionError: + is_parent_and_ancestors_world_traversable = False + except (FileNotFoundError, NotADirectoryError) as err: + msg = (f"Error while saving the frames to the file: " + f"{filename!a}. Error: {err!a}") + raise type(err)(msg) from None + except OSError as err: + _SAVEFRAME_LOGGER.warning( + f"Error while trying to determine if the parent directory: " + f"{parent_dir!a} and the ancestors are world traversable. " + f"Error: {err!a}") + if not is_parent_and_ancestors_world_traversable: + _SAVEFRAME_LOGGER.warning( + f"The parent directory {parent_dir!a} or an ancestor is not world " + f"traversable (i.e., the execute bit of one of the ancestors is 0). " + f"The filename {filename!a} might not be accessible by others.") + return filename + + +def _validate_frames(frames, utility): + """ + Validate the value of ``frames``. + + This utility validates / parses the ``frames`` based on the following formats: + 1. Single frame: frames=frame or frames=['frame'] + 2. Multiple frames: frames='frame1,frame2,...' (for `pyflyby/bin/saveframe` + script) or frames=['frame1', 'frame2', ...] (for `saveframe` function). + 3. Range of frames: frames='first_frame..last_frame' + 4. Range from first frame to bottom: frames='first_frame..' + 5. Number of frames from bottom: frames=num + + NOTE: Each frame is represented as 'file_regex:lineno:function_name' + + :param frames: + Frames to validate + :param utility: + Indicates whether this helper is invoked by the ``pyflyby.saveframe`` function + or the ``pyflyby/bin/saveframe`` script. See `_validate_saveframe_arguments` + for more info. + :return: + A tuple of 2 items: + 1. Parsed frames: + - None if ``frames`` is None. + - If ``frames=num`` (case 5), this is the integer ``num``. + - Otherwise, this is a list of tuples. Each tuple represents a frame + and consists of three items: ``filename``, ``lineno``, and ``function_name``. + - Example: For ``frames='/some/foo.py:32:,/some/bar.py:28:func'``, the + first item would be ``[('/some/foo.py', 32, ''), ('/some/bar.py', 28, 'func')]``. + + 2. The format of the ``frames``: + - None if ``frames`` is None. + - For cases 1 and 2, the format is 'list'. + - For cases 3 and 4, the format is 'range'. + - For case 5, the format is 'num'. + """ + if frames is None: + _SAVEFRAME_LOGGER.info( + f"{'`frames` parameter' if utility == 'function' else '--frames argument'} " + f"is not passed explicitly. The first frame from the bottom will be " + f"saved by default.") + return None, None + _SAVEFRAME_LOGGER.info(f"Validating frames: {frames!a}") + try: + # Handle frames as an integer. + return int(frames), "num" + except (ValueError, TypeError): + pass + # Boolean to denote if the `frames` parameter is passed in the range format. + is_range = False + if isinstance(frames, str) and ',' in frames and utility == 'function': + raise ValueError( + f"Error while validating frames: {frames!a}. If you want to pass multiple " + f"frames, pass a list/tuple of frames like {frames.split(',')} rather " + f"than a comma separated string of frames.") + if isinstance(frames, (list, tuple)): + for frame in frames: + if ',' in frame: + raise ValueError( + f"Invalid frame: {frame!a} in frames: {frames} as it " + f"contains character ','. If you are trying to pass multiple " + f"frames, pass them as separate items in the list.") + frames = ','.join(frames) + all_frames = [frame.strip() for frame in frames.split(',')] + # Handle the single frame and the range of frame formats. + if len(all_frames) == 1: + all_frames = [frame.strip() for frame in frames.split('..')] + if len(all_frames) > 2: + raise ValueError( + f"Error while validating frames: {frames!a}. If you want to pass a " + f"range of frames, the correct syntax is 'first_frame..last_frame'") + elif len(all_frames) == 2: + is_range = True + else: + is_range = False + + parsed_frames = [] + for idx, frame in enumerate(all_frames): + frame_parts = frame.split(':') + # Handle 'first_frame..' format (case 4.). + if (idx == 1 and len(frame_parts) == 1 and frame_parts[0] == '' and + is_range is True): + parsed_frames.append(frame_parts) + break + if len(frame_parts) != 3: + raise ValueError( + f"Error while validating frame: {frame!a}. The correct syntax for a " + f"frame is 'file_regex:line_no:function_name' but frame {frame!a} " + f"contains {len(frame_parts)-1} ':'.") + if not frame_parts[0]: + raise ValueError( + f"Error while validating frame: {frame!a}. The filename / file " + f"regex must be passed in a frame.") + # Validate the line number passed in the frame. + if frame_parts[1]: + try: + frame_parts[1] = int(frame_parts[1]) + except ValueError: + raise ValueError(f"Error while validating frame: {frame!a}. The " + f"line number {frame_parts[1]!a} can't be " + f"converted to an integer.") + parsed_frames.append(frame_parts) + + return parsed_frames, "range" if is_range else "list" + + +def _is_variable_name_valid(name): + """ + Is ``name`` a valid variable name. + + :param name: + Variable name to validate. + :return: + `True` or `False`. + """ + if not name.isidentifier(): + return False + if keyword.iskeyword(name): + return False + return True + + +def _validate_variables(variables, utility): + """ + Validate the value of ``variables``. + + If there are invalid variable names, filter them out and log a warning. + + :param variables: + Variables to validate + :param utility: + Indicates whether this helper is invoked by the ``pyflyby.saveframe`` function + or the ``pyflyby/bin/saveframe`` script. See `_validate_saveframe_arguments` + for more info. + :return: + A list of filtered variables post validation. + """ + if variables is None: + return + _SAVEFRAME_LOGGER.info(f"Validating variables: {variables!a}") + if isinstance(variables, str) and ',' in variables and utility == 'function': + raise ValueError( + f"Error while validating variables: {variables!a}. If you want to " + f"pass multiple variable names, pass a list/tuple of names like " + f"{variables.split(',')} rather than a comma separated string of names.") + if isinstance(variables, (list, tuple)): + all_variables = variables + else: + all_variables = [variable.strip() for variable in variables.split(',')] + invalid_variable_names = [variable for variable in all_variables + if not _is_variable_name_valid(variable)] + if invalid_variable_names: + _SAVEFRAME_LOGGER.warning( + f"Invalid variable names: {invalid_variable_names}. Skipping these " + f"variables and continuing.") + # Filter out invalid variables. + all_variables = [variable for variable in all_variables + if variable not in invalid_variable_names] + return all_variables + + +def _validate_saveframe_arguments( + filename, frames, variables, exclude_variables, utility='function'): + """ + Validate and sanitize the parameters supported by the `saveframe` function. + + :param filename: + File path in which to save the frame's info. + :param frames: + Specific error frames to save. + :param variables: + Local variables to include in each frame info. + :param exclude_variables: + Local variables to exclude from each frame info. + :param utility: + Indicates whether this helper is invoked by the ``pyflyby.saveframe`` function + or the ``pyflyby/bin/saveframe`` script. Allowed values are 'function' and + 'script'. The saveframe function and script accept different types of + values for their arguments. This parameter helps distinguish between them + to ensure proper argument and parameter validation. + :return: + A tuple of ``filename``, ``frames``, ``variables`` and ``exclude_variables`` + post validation. + """ + allowed_utility_values = ['function', 'script'] + if utility not in allowed_utility_values: + raise ValueError( + f"Invalid value for parameter 'utility': {utility!a}. Allowed values " + f"are: {allowed_utility_values}") + filename = _validate_filename(filename, utility) + frames =_validate_frames(frames, utility) + if variables and exclude_variables: + raise ValueError( + f"Cannot pass both {'`variables`' if utility == 'function' else '--variables'} " + f"and {'`exclude_variables`' if utility == 'function' else '--exclude_variables'} " + f"{'parameters' if utility == 'function' else 'arguments'}.") + variables = _validate_variables(variables, utility) + exclude_variables = _validate_variables(exclude_variables, utility) + if not (variables or exclude_variables): + _SAVEFRAME_LOGGER.info( + f"Neither {'`variables`' if utility == 'function' else '--variables'} " + f"nor {'`exclude_variables`' if utility == 'function' else '--exclude_variables'} " + f"{'parameter' if utility == 'function' else 'argument'} is passed. " + f"All the local variables from the frames will be saved.") + + return filename, frames, variables, exclude_variables + + +def saveframe(filename=None, frames=None, variables=None, exclude_variables=None): + """ + Utility to save information for debugging / reproducing an issue. + + Usage: + -------------------------------------------------------------------------- + If you have a piece of code that is currently failing due to an issue + originating from another team's code, and you cannot share your private + code as a reproducer, use this function to save relevant information to a file. + While in an interactive session such as IPython, Jupyter Notebook, or a + debugger (pdb/ipdb), you can call this function after your code raised + an error to capture and save error frames specific to the other team's + codebase. Share the generated file with the other team, enabling them to + reproduce and diagnose the issue independently. + + Information saved in the file: + -------------------------------------------------------------------------- + This utility captures and saves error stack frames to a file. It includes the + values of local variables from each stack frame, as well as metadata about each + frame and the exception raised by the user's code. Following is the sample + structure of the info saved in the file: + + :: + + { + # 5th frame from the bottom + 5: { + 'frame_index': 5, + 'filename': '/path/to/file.py', + 'lineno': 3423, + 'function_name': 'func1', + 'function_qualname': 'FooClass.func1', + 'function_object': , + 'module_name': '' + 'frame_identifier': '/path/to/file.py,3423,func1', + 'code': '... python code line ...' + 'variables': {'local_variable1': , 'local_variable2': , ...} + }, + # 17th frame from the bottom + 17: { + 'frame_index': 17, + ... + }, + ... + 'exception_full_string': f'{exc.__class.__name__}: {exc}' + 'exception_object': exc, + 'exception_string': str(exc), + 'exception_class_name': exc.__class__.__name__, + 'exception_class_qualname': exc.__class__.__qualname__, + 'traceback': '(multiline traceback) + } + + .. note:: + - The above data gets saved in the file in pickled form. + - In the above data, the key of each frame's entry is the index of that frame + from the bottom of the error stack trace. So the first frame from the bottom + (the error frame) has index 1, and so on. + - 'variables' key in each frame's entry stores the local variables of that frame. + - The 'exception_object' key stores the actual exception object but without + the __traceback__ info (for security reasons). + + **Example usage**: + + :: + + # In an interactive session (ipython, jupyter notebook, etc.) + + >> + >> saveframe(filename=/path/to/file) # Saves the first frame from the bottom + >> saveframe(filename=/path/to/file, frames=frames_to_save, + .. variables=local_variables_to_save, exclude_variables=local_variables_to_exclude) + + # In an interactive debugger (pdb / ipdb) + + >> + >> ipdb.pm() # start a debugger + >> ipdb> from pyflyby import saveframe + >> ipdb> saveframe(filename=/path/to/file) # Saves the frame which you are currently at + >> ipdb> saveframe(filename=/path/to/file, frames=frames_to_save, + .. variables=local_variables_to_include, exclude_variables=local_variables_to_exclude) + + For non-interactive use cases (e.g., a failing script or command), checkout + `pyflyby/bin/saveframe` script. + + :param filename: + File path in which to save the frame information. If this file already + exists, it will be overwritten; otherwise, a new file will be created + with permission mode '0o644'. If this parameter is not passed, the info + gets saved in the 'saveframe.pkl' file in the current working directory. + + :param frames: + Error stack frames to save. A single frame follows the format + 'filename:line_no:function_name', where: + - filename: The file path or a regex pattern matching the file path + (displayed in the stack trace) of that error frame. + - line_no (Optional): The code line number (displayed in the stack trace) + of that error frame. + - function_name (Optional): The function name (displayed in the stack trace) + of that error frame. + + Partial frames are also supported where line_no and/or function_name can + be omitted: + - filename:: -> Includes all the frames that matches the filename + - filename:line_no: -> Include all the frames that matches specific line + in any function in the filename + - filename::function_name -> Include all the frames that matches any line + in the specific function in the filename + + Following formats are supported to pass the frames: + + 1. Single frame: + frames='frame' + Example: frames='/path/to/file.py:24:some_func' + Includes only the specified frame. + + 2. Multiple frames: + frames=['frame1', 'frame2', ...] + Example: frames=['/dir/foo.py:45:', '.*/dir2/bar.py:89:caller'] + Includes all specified frames. + + 3. Range of frames: + frames='first_frame..last_frame' + Example: frames='/dir/foo.py:45:get_foo../dir3/blah.py:23:myfunc' + Includes all the frames from first_frame to last_frame (both inclusive). + + 4. Range from first_frame to bottom: + frames='first_frame..' + Example: frames='/dir/foo.py:45:get_foo..' + Includes all the frames from first_frame to the bottom of the stack trace. + + 5. Number of Frames from Bottom: + frames=num + Example: frames=5 + Includes the last 'num' frames from the bottom of the stack trace. + + Default behavior if this parameter is not passed: + - When user is in a debugger (ipdb/pdb): Save the frame the user is + currently at. + - When user is not in a debugger: Save the first frame from the bottom + (the error frame). + + :param variables: + Local variables to include in each frame. It accepts a list/tuple of + variable names or a string if there is only 1 variable. + + If this parameter is not passed, save all the local variables of the + included frames. + + :param exclude_variables: + Local variables to exclude from each frame. It accepts a list/tuple of + variable names or a string if there is only 1 variable. + + If this parameter is not passed, save all the local variables of the + included frames as per the ``variables`` parameter value. + + :return: + The file path in which the frame info is saved. + """ + if not hasattr(sys, 'last_value'): + raise RuntimeError( + "No exception is raised currently for which to save the frames. " + "Make sure that an uncaught exception is raised before calling the " + "`saveframe` function.") + # Get the latest exception raised. + exception_obj = sys.last_value + + if frames is None: + # Get the instance of the interactive session the user is currently in. + interactive_session_obj = sys._getframe().f_back.f_back.f_locals.get('self') + # If the user is currently in a debugger (ipdb/pdb), save the frame the + # user is currently at in the debugger. + if interactive_session_obj and hasattr(interactive_session_obj, 'curframe'): + current_frame = interactive_session_obj.curframe + frames = (f"{current_frame.f_code.co_filename}:{current_frame.f_lineno}:" + f"{current_frame.f_code.co_qualname}") + + _SAVEFRAME_LOGGER.info("Validating arguments passed.") + filename, frames, variables, exclude_variables = _validate_saveframe_arguments( + filename, frames, variables, exclude_variables) + _SAVEFRAME_LOGGER.info( + f"Saving frames and metadata for the exception: '{exception_obj!a}") + _save_frames_and_exception_info_to_file( + filename=filename, frames=frames, variables=variables, + exclude_variables=exclude_variables, exception_obj=exception_obj) + return filename \ No newline at end of file diff --git a/tests/test_saveframe.py b/tests/test_saveframe.py new file mode 100644 index 00000000..7b9fe5b0 --- /dev/null +++ b/tests/test_saveframe.py @@ -0,0 +1,822 @@ +from __future__ import annotations + +import os +import pytest +import random +import pickle +from shutil import rmtree +import subprocess +import sys +from tempfile import mkdtemp +from textwrap import dedent +from contextlib import contextmanager + +from pyflyby import Filename, saveframe + +PYFLYBY_HOME = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +BIN_DIR = os.path.join(PYFLYBY_HOME, "bin") + +exception_info_keys = { + 'exception_full_string', 'exception_object', 'exception_string', + 'exception_class_name', 'exception_class_qualname', 'traceback'} + + +@pytest.fixture +def tmpdir(request): + """ + A temporary directory which is temporarily added to sys.path. + """ + d = mkdtemp(prefix="pyflyby_test_saveframe_", suffix=".tmp") + d = Filename(d).real + def cleanup(): + # Unload temp modules. + for name, module in sorted(sys.modules.items()): + if (getattr(module, "__file__", None) or "").startswith(str(d)): + del sys.modules[name] + # Clean up sys.path. + sys.path.remove(str(d)) + # Clean up directory on disk. + rmtree(str(d)) + request.addfinalizer(cleanup) + sys.path.append(str(d)) + return d + + +def load_pkl(filename): + with open(filename, mode='rb') as f: + data = pickle.load(f) + return data + + +def writetext(filename, text, mode='w'): + text = dedent(text) + assert isinstance(filename, Filename) + with open(str(filename), mode) as f: + f.write(text) + return filename + + +@contextmanager +def chdir(path): + old_cwd = os.getcwd() + os.chdir(str(path)) + try: + yield + finally: + os.chdir(old_cwd) + + +def get_random(): + return int(random.random() * 10 ** 9) + + +def run_command(command): + result = subprocess.run(command, capture_output=True) + return result.stderr.decode('utf-8').strip().split('\n') + + +def frames_metadata_checker(tmpdir, pkg_name, filename): + """ + Check if the metadata of the frames is correctly written in the ``filename``. + """ + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + + assert data[1]["code"] == 'raise ValueError("Error is raised")' + assert data[1]["frame_index"] == 1 + assert data[1]["filename"] == str( + tmpdir / pkg_name / "pkg1" / "pkg2" / "mod3.py") + assert data[1]["lineno"] == 6 + assert data[1]["function_name"] == "func3" + assert data[1]["function_qualname"] == "func3" + assert data[1]["module_name"] == f"{pkg_name}.pkg1.pkg2.mod3" + assert data[1]["frame_identifier"] == ( + f"{data[1]['filename']},{data[1]['lineno']},{data[1]['function_name']}") + + assert data[2]["code"] == 'func3()' + assert data[2]["frame_index"] == 2 + assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") + assert data[2]["lineno"] == 10 + assert data[2]["function_name"] == "func2" + assert data[2]["function_qualname"] == "mod2_cls.func2" + assert data[2]["module_name"] == f"{pkg_name}.pkg1.mod2" + assert data[2]["frame_identifier"] == ( + f"{data[2]['filename']},{data[2]['lineno']},{data[2]['function_name']}") + + assert data[3]["code"] == 'obj.func2()' + assert data[3]["frame_index"] == 3 + assert data[3]["filename"] == str(tmpdir / pkg_name / "mod1.py") + assert data[3]["lineno"] == 7 + assert data[3]["function_name"] == "func2" + assert data[3]["function_qualname"] == "func2" + assert data[3]["module_name"] == f"{pkg_name}.mod1" + assert data[3]["frame_identifier"] == ( + f"{data[3]['filename']},{data[3]['lineno']},{data[3]['function_name']}") + + assert data[4]["code"] == 'func2()' + assert data[4]["frame_index"] == 4 + assert data[4]["filename"] == str(tmpdir / pkg_name / "mod1.py") + assert data[4]["lineno"] == 12 + assert data[4]["function_name"] == "func1" + assert data[4]["function_qualname"] == "func1" + assert data[4]["module_name"] == f"{pkg_name}.mod1" + assert data[4]["frame_identifier"] == ( + f"{data[4]['filename']},{data[4]['lineno']},{data[4]['function_name']}") + + assert data[5]["code"] == 'func1()' + assert data[5]["frame_index"] == 5 + assert data[5]["filename"] == str(tmpdir / pkg_name / "__init__.py") + assert data[5]["lineno"] == 6 + assert data[5]["function_name"] == "init_func1" + assert data[5]["function_qualname"] == "init_func1" + assert data[5]["module_name"] == pkg_name + assert data[5]["frame_identifier"] == ( + f"{data[5]['filename']},{data[5]['lineno']},{data[5]['function_name']}") + + +def frames_local_variables_checker(tmpdir, pkg_name, filename): + """ + Check if the local variables of the frames are correctly written in the + ``filename``. + """ + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + + assert set(data[1]['variables'].keys()) == {'func3_var3', 'var1', 'var2'} + assert pickle.loads(data[1]['variables']['func3_var3']) == True + assert pickle.loads(data[1]['variables']['var1']) == [4, 'foo', 2.4] + assert pickle.loads(data[1]['variables']['var2']) == 'blah' + + assert set(data[2]['variables'].keys()) == {'self', 'var1', 'var2'} + self_val = pickle.loads(data[2]['variables']['self']) + mod2 = __import__(f"{pkg_name}.pkg1.mod2", fromlist=['dummy'], level=0) + assert isinstance(self_val, mod2.mod2_cls) + assert pickle.loads(data[2]['variables']['var1']) == 'foo' + assert pickle.loads(data[2]['variables']['var2']) == (4, 9, 10) + + assert set(data[3]['variables'].keys()) == {'obj', 'var1', 'var2'} + obj_val = pickle.loads(data[3]['variables']['obj']) + assert isinstance(obj_val, mod2.mod2_cls) + assert pickle.loads(data[3]['variables']['var1']) == 'func2' + assert pickle.loads(data[3]['variables']['var2']) == 34 + + assert set(data[4]['variables'].keys()) == {'func1_var2', 'var1'} + assert pickle.loads(data[4]['variables']['func1_var2']) == 4.56 + assert pickle.loads(data[4]['variables']['var1']) == [4, 5, 2] + + assert set(data[5]['variables'].keys()) == {'var1', 'var2'} + assert pickle.loads(data[5]['variables']['var1']) == 3 + assert pickle.loads(data[5]['variables']['var2']) == 'blah' + + +def exception_info_checker(filename): + """ + Check if the exception info is correctly written in the ``filename``. + """ + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys + assert data['exception_class_name'] == 'ValueError' + assert data['exception_class_qualname'] == 'ValueError' + assert data['exception_full_string'] == 'ValueError: Error is raised' + assert isinstance(data['exception_object'], ValueError) + # Traceback shouldn't be pickled for security reasons. + assert data['exception_object'].__traceback__ == None + assert data['exception_string'] == 'Error is raised' + + +def create_pkg(tmpdir): + """ + Create a pacakge with multiple nested sub-packages and modules in ``tmpdir``. + """ + pkg_name = f"saveframe_{int(random.random() * 10**9)}" + os.mkdir(str(tmpdir / pkg_name)) + writetext(tmpdir / pkg_name / "__init__.py", f""" + from {pkg_name}.mod1 import func1 + def init_func1(): + var1 = 3 + var2 = 'blah' + func1() + + def init_func2(): + pass + + def init_func3(): + var1 = 'init_func3' + var2 = 24 + try: + func1() + except ValueError as err: + raise TypeError("Chained exception") from err + """) + writetext(tmpdir / pkg_name / "mod1.py", f""" + from {pkg_name}.pkg1.mod2 import mod2_cls + def func2(): + var1 = "func2" + var2 = 34 + obj = mod2_cls() + obj.func2() + + def func1(): + var1 = [4, 5, 2] + func1_var2 = 4.56 + func2() + """) + os.mkdir(str(tmpdir / pkg_name / "pkg1")) + writetext(tmpdir / pkg_name / "pkg1" / "__init__.py", "") + writetext(tmpdir / pkg_name / "pkg1" / "mod2.py", f""" + from {pkg_name}.pkg1.pkg2.mod3 import func3 + class mod2_cls: + def __init__(self): + pass + def func2(self): + var1 = 'foo' + var2 = (4, 9, 10) + var3 = lambda x: x+1 + func3() + """) + os.mkdir(str(tmpdir/ pkg_name / "pkg1" / "pkg2")) + writetext(tmpdir / pkg_name / "pkg1" / "pkg2" / "__init__.py", "") + writetext(tmpdir / pkg_name / "pkg1" / "pkg2" / "mod3.py", """ + def func3(): + var1 = [4, 'foo', 2.4] + var2 = 'blah' + func3_var3 = True + raise ValueError("Error is raised") + """) + return pkg_name + + +def test_saveframe_invalid_filename_1(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / pkg_name)) + err_msg = (f"{str(tmpdir / pkg_name)!a} is an already existing directory. " + f"Please pass a different filename.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_filename_2(tmpdir): + pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_dir_{get_random()}" / f"saveframe_{get_random()}.pkl") + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(FileNotFoundError) as err: + saveframe(filename=filename) + err_msg = (f"Error while saving the frames to the file: {filename!a}. Error: " + f"FileNotFoundError(2, 'No such file or directory')") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_frames_1(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="foo.py:") + err_msg = ("Error while validating frame: 'foo.py:'. The correct syntax for " + "a frame is 'file_regex:line_no:function_name' but frame 'foo.py:' " + "contains 1 ':'.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_frames_2(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=":12:func1") + err_msg = ("Error while validating frame: ':12:func1'. The filename / file " + "regex must be passed in a frame.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_frames_3(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="file.py:12:func1,file1.py::") + err_msg = ("Error while validating frames: 'file.py:12:func1,file1.py::'. " + "If you want to pass multiple frames, pass a list/tuple of frames " + "like ['file.py:12:func1', 'file1.py::'] rather than a comma " + "separated string of frames.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_frames_4(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=["file.py:12:func1", "file1.py::,file2.py:34:func2"]) + err_msg = ( + "Invalid frame: 'file1.py::,file2.py:34:func2' in frames: ['file.py:12:func1', " + "'file1.py::,file2.py:34:func2'] as it contains character ','. If you are " + "trying to pass multiple frames, pass them as separate items in the list.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_frames_5(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="file.py:12:func1..file2.py::..") + err_msg = ("Error while validating frames: 'file.py:12:func1..file2.py::..'. " + "If you want to pass a range of frames, the correct syntax is " + "'first_frame..last_frame'") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_frames_6(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="file.py:foo:func1") + err_msg = ("Error while validating frame: 'file.py:foo:func1'. The line " + "number 'foo' can't be converted to an integer.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_variables_and_exclude_variables(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(variables="foo", exclude_variables="bar") + err_msg = "Cannot pass both `variables` and `exclude_variables` parameters." + assert str(err.value) == err_msg + + +def test_saveframe_invalid_variables_1(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + variables="var1,var2") + err_msg = ("Error while validating variables: 'var1,var2'. If you want to pass " + "multiple variable names, pass a list/tuple of names like ['var1', " + "'var2'] rather than a comma separated string of names.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_variables_2(tmpdir, caplog): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + variables=["var1", "1var2"]) + log_messages = [record.message for record in caplog.records] + warning_msg = ("Invalid variable names: ['1var2']. Skipping these variables " + "and continuing.") + assert warning_msg in log_messages + delattr(sys, "last_value") + + +def test_saveframe_invalid_exclude_variables_1(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + exclude_variables="var1,var2") + err_msg = ("Error while validating variables: 'var1,var2'. If you want to pass " + "multiple variable names, pass a list/tuple of names like ['var1', " + "'var2'] rather than a comma separated string of names.") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_invalid_exclude_variables_2(tmpdir, caplog): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + exclude_variables=["var1", "1var2"]) + log_messages = [record.message for record in caplog.records] + warning_msg = ("Invalid variable names: ['1var2']. Skipping these variables " + "and continuing.") + assert warning_msg in log_messages + delattr(sys, "last_value") + + +def test_saveframe_no_error_raised(tmpdir): + if hasattr(sys, "last_value"): + delattr(sys, "last_value") + pkg_name = create_pkg(tmpdir) + with pytest.raises(RuntimeError) as err: + exec(f"from {pkg_name} import init_func2; init_func2()") + saveframe() + err_msg = ("No exception is raised currently for which to save the frames. " + "Make sure that an uncaught exception is raised before calling " + "the `saveframe` function.") + assert str(err.value) == err_msg + + +def test_saveframe_frame_format_1(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + # Format: 'filename:line_no:func_name' + saveframe(filename=filename, frames=f"pkg1/mod2.py:10:func2") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {2} + assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") + assert data[2]["function_name"] == "func2" + + # Format: 'filename::' + saveframe(filename=filename, frames="mod1.py::") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {3, 4} + assert data[3]["filename"] == str(tmpdir / pkg_name / "mod1.py") + assert data[3]["function_name"] == "func2" + + # Format: 'filename::func_name' + saveframe(filename=filename, frames=f"{pkg_name}/mod1.py::func1") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {4} + assert data[4]["filename"] == str(tmpdir / pkg_name / "mod1.py") + assert data[4]["function_name"] == "func1" + delattr(sys, "last_value") + + +def test_saveframe_frame_format_2(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + + saveframe(filename=filename, frames=["__init__.py::", "pkg1/mod2.py:10:func2"]) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {2, 5} + assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") + assert data[2]["function_qualname"] == "mod2_cls.func2" + + assert data[5]["filename"] == str(tmpdir / pkg_name / "__init__.py") + assert data[5]["function_qualname"] == "init_func1" + + saveframe(filename=filename, frames=["pkg1/pkg2/mod3.py:6:", "mod1::"]) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 3, 4} + assert data[1]["filename"] == str(tmpdir / pkg_name / "pkg1" / "pkg2" / "mod3.py") + assert data[1]["function_qualname"] == "func3" + delattr(sys, "last_value") + + +def test_saveframe_frame_format_3(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + saveframe(filename=filename, frames=3) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3} + + saveframe(filename=filename, frames=5) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + delattr(sys, "last_value") + + +def test_saveframe_frame_format_4(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + saveframe(filename=filename, frames="pkg1/mod2.py::..__init__.py:6:init_func1") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {2, 3, 4, 5} + + with pytest.raises(ValueError) as err: + saveframe(filename=filename, + frames="pkg1/mod3.py::..__init__.py:6:init_func1") + err_msg = "No frame in the traceback matched the frame: 'pkg1/mod3.py::'" + assert str(err.value) == err_msg + delattr(sys, "last_value") + + +def test_saveframe_frame_format_5(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=f"{str(tmpdir)}/{pkg_name}/mod1.py::..") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4} + delattr(sys, "last_value") + + +def test_saveframe_variables(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=5, variables=['var1', 'var2']) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + + assert set(data[1]["variables"].keys()) == {"var1", "var2"} + assert set(data[2]["variables"].keys()) == {"var1", "var2"} + assert set(data[3]["variables"].keys()) == {"var1", "var2"} + assert set(data[4]["variables"].keys()) == {"var1"} + assert set(data[5]["variables"].keys()) == {"var1", "var2"} + delattr(sys, "last_value") + + +def test_saveframe_exclude_variables(tmpdir, caplog): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=5, exclude_variables=['var1', 'var2']) + log_messages = [record.message for record in caplog.records] + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + + assert set(data[1]["variables"].keys()) == {"func3_var3"} + assert set(data[2]["variables"].keys()) == {"self"} + assert set(data[3]["variables"].keys()) == {"obj"} + assert set(data[4]["variables"].keys()) == {"func1_var2"} + assert set(data[5]["variables"].keys()) == set() + + warning_msg = ( + f"Cannot pickle variable: 'var3' for frame: 'File: {str(tmpdir)}/{pkg_name}" + "/pkg1/mod2.py, Line: 10, Function: mod2_cls.func2'. Error: TypeError" + "(\"cannot pickle 'function' object\"). Skipping this variable and " + "continuing.") + assert warning_msg in log_messages + delattr(sys, "last_value") + + +def test_saveframe_defaults(tmpdir, caplog): + pkg_name = create_pkg(tmpdir) + with chdir(tmpdir): + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe() + log_messages = [record.message for record in caplog.records] + # Test that saveframe.pkl file in the current working directory is used by + # default. + info_message = (f"Filename is not passed explicitly using the `filename` " + f"parameter. The frame info will be saved in the file: " + f"'{str(tmpdir)}/saveframe.pkl'.") + assert info_message in log_messages + info_message = ("`frames` parameter is not passed explicitly. The first frame " + "from the bottom will be saved by default.") + assert info_message in log_messages + assert os.path.basename(filename) == 'saveframe.pkl' + data = load_pkl(filename) + # Test that only first frame from the bottom (index = 1) is stored in the + # data by default. + assert set(data.keys()) == exception_info_keys | {1} + delattr(sys, "last_value") + + +def test_saveframe_frame_metadata(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) + + frames_metadata_checker(tmpdir, pkg_name, filename) + delattr(sys, "last_value") + + +def test_saveframe_local_variables_data(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) + + frames_local_variables_checker(tmpdir, pkg_name, filename) + delattr(sys, "last_value") + + +def test_saveframe_exception_info(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=0) + + exception_info_checker(filename) + delattr(sys, "last_value") + + +def test_saveframe_chained_exceptions(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func3; init_func3()") + except TypeError as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=['__init__.py::init_func3', f'.*/{pkg_name}/.*::']) + data = load_pkl(filename) + + assert 1 in set(data.keys()) + assert data[1]["code"] == 'raise TypeError("Chained exception") from err' + assert data[1]["frame_index"] == 1 + assert data[1]["filename"] == str( + tmpdir / pkg_name / "__init__.py") + assert data[1]["lineno"] == 17 + assert data[1]["function_name"] == "init_func3" + assert data[1]["function_qualname"] == "init_func3" + assert data[1]["module_name"] == f"{pkg_name}" + assert data[1]["frame_identifier"] == ( + f"{data[1]['filename']},{data[1]['lineno']},{data[1]['function_name']}") + assert len(set(data.keys()) - exception_info_keys) == 5 + + +def test_saveframe_cmdline_no_exception(): + command = [BIN_DIR+"/saveframe", "python", "-c", "import os;"] + err = run_command(command) + err_msg = "Error: No exception is raised by the program: 'python -c import os;'" + assert err_msg in err + + +def test_saveframe_cmdline_variables_and_exclude_variables(): + command = [BIN_DIR + "/saveframe", "--variables", "foo,bar", + "--exclude_variables", "var", "python", "-c", "import os;"] + err = run_command(command) + err_msg = ("ValueError: Cannot pass both --variables and --exclude_variables " + "arguments.") + assert err_msg in err + + +def test_saveframe_cmdline_invalid_command_1(): + command = [BIN_DIR+"/saveframe", "python", "-c"] + err = run_command(command) + err_msg = "Error: Please pass a valid script / command to run!" + assert err_msg in err + + +def test_saveframe_cmdline_invalid_command_2(): + command = [BIN_DIR+"/saveframe", "python"] + err = run_command(command) + err_msg = "Error: Please pass a valid script / command to run!" + assert err_msg in err + + +def test_saveframe_cmdline_invalid_command_3(): + command = [BIN_DIR+"/saveframe"] + err = run_command(command) + err_msg = "Error: Please pass a valid script / command to run!" + assert err_msg in err + + +def test_saveframe_cmdline_variables(tmpdir): + pkg_name = create_pkg(tmpdir) + tmp_mod = writetext(tmpdir / f"tmp_mod_{get_random()}.py", f""" + import sys + sys.path.append('{tmpdir}') + from {pkg_name} import init_func1 + init_func1() + """) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR + "/saveframe", "--filename", filename, "--frames", "5", + "--variables", "var1, var2", "python", str(tmp_mod)] + run_command(command) + + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + + assert set(data[1]["variables"].keys()) == {"var1", "var2"} + assert set(data[2]["variables"].keys()) == {"var1", "var2"} + assert set(data[3]["variables"].keys()) == {"var1", "var2"} + assert set(data[4]["variables"].keys()) == {"var1"} + assert set(data[5]["variables"].keys()) == {"var1", "var2"} + + +def test_saveframe_cmdline_exclude_variables(tmpdir): + pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR + "/saveframe", "--filename", filename, "--frames", "5", + "--exclude_variables", "var1,var2", "python", "-c", + f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " + f"init_func1; init_func1()"] + run_command(command) + + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} + + assert set(data[1]["variables"].keys()) == {"func3_var3"} + assert set(data[2]["variables"].keys()) == {"self"} + assert set(data[3]["variables"].keys()) == {"obj"} + assert set(data[4]["variables"].keys()) == {"func1_var2"} + assert set(data[5]["variables"].keys()) == set() + + +def test_saveframe_cmdline_frame_metadata(tmpdir): + pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR+"/saveframe", "--filename", filename, "--frames", "5", + "python", "-c", + f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " + f"init_func1; init_func1()"] + run_command(command) + + frames_metadata_checker(tmpdir, pkg_name, filename) + + +def test_saveframe_cmdline_local_variables_data(tmpdir): + pkg_name = create_pkg(tmpdir) + tmp_mod = writetext(tmpdir / f"tmp_mod_{get_random()}.py", f""" + import sys + sys.path.append('{tmpdir}') + from {pkg_name} import init_func1 + init_func1() + """) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR + "/saveframe", "--filename", filename, "--frames", "5", + "python", str(tmp_mod)] + run_command(command) + + frames_local_variables_checker(tmpdir, pkg_name, filename) + + +def test_saveframe_cmdline_exception_info(tmpdir): + pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR + "/saveframe", "--filename", filename, "--frames", "0", + "python", "-c", + f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " + f"init_func1; init_func1()"] + run_command(command) + + exception_info_checker(filename) From c58dd646fe859e90fedf98b14f8e67c6011b71fc Mon Sep 17 00:00:00 2001 From: Sachin Saharan Date: Fri, 27 Sep 2024 21:38:15 +0530 Subject: [PATCH 2/4] Addressed review comments --- bin/saveframe | 37 +++--- lib/python/pyflyby/_saveframe.py | 157 +++++++++++++++--------- tests/test_saveframe.py | 199 +++++++++++++++++++++++++++++-- 3 files changed, 311 insertions(+), 82 deletions(-) diff --git a/bin/saveframe b/bin/saveframe index 73eb7133..a6c07efb 100755 --- a/bin/saveframe +++ b/bin/saveframe @@ -4,10 +4,10 @@ Utility to save information for debugging / reproducing an issue. Usage: If you have a script or command that is currently failing due to an issue -originating from another team's code, and you cannot share your private code as +originating from upstream code, and you cannot share your private code as a reproducer, use this utility to save relevant information to a file (e.g., -error frames specific to the other team's codebase). Share the generated file -with the other team, enabling them to reproduce and diagnose the issue +error frames specific to the upstream codebase). Share the generated file +with the upstream team, enabling them to reproduce and diagnose the issue independently. Information saved in the file: @@ -94,7 +94,10 @@ def getargs(): ) parser.add_argument( "--frames", default=None, - help="Error stack frames to save. A single frame follows the format " + help="Error stack frames to save.\n" + "Default behavior: If --frames is not passed, the first frame from " + "the bottom (the error frame) is saved.\n\n" + "A single frame follows the format " "'filename:line_no:function_name', where:\n" " - filename: The file path or a regex pattern matching the file " "path (displayed in the stack trace) of that error frame.\n" @@ -104,10 +107,10 @@ def getargs(): "the stack trace) of that error frame.\n\n" "Partial frames are also supported where line_no and/or function_name " "can be omitted:\n" - " - filename:: -> Includes all the frames that matches the filename\n" - " - filename:line_no: -> Include all the frames that matches " + " - 'filename::' -> Includes all the frames that matches the filename\n" + " - 'filename:line_no:' -> Include all the frames that matches " "specific line in any function in the filename\n" - " - filename::function_name -> Include all the frames that matches " + " - 'filename::function_name' -> Include all the frames that matches " "any line in the specific function in the filename\n\n" "Following formats are supported to pass the frames:\n\n" "1. Single frame:\n" @@ -129,9 +132,7 @@ def getargs(): "5. Number of Frames from Bottom:\n" " --frames=num\n" " Example: --frames=5\n" - " Includes the last 'num' frames from the bottom of the stack trace.\n\n" - "Default behavior: If --frames is not passed, the first frame from " - "the bottom is saved." + " Includes the last 'num' frames from the bottom of the stack trace." ) parser.add_argument( "--variables", default=None, @@ -180,21 +181,19 @@ def which(program): return None -def execfile(filepath, globals): +def execfile(filepath): """ Execute the script stored in ``filepath``. :param filepath: Path of the script to execute. - :param globals: - globals context to use while executing ``filepath``. """ - globals.update({ + globals_cpy.update({ "__file__": filepath, "__name__": "__main__", }) with open(filepath, 'rb') as file: - exec(compile(file.read(), filepath, 'exec'), globals) + exec(compile(file.read(), filepath, 'exec'), globals_cpy) def run_program(command): @@ -224,7 +223,7 @@ def run_program(command): # Set sys.argv to mimic the command execution. sys.argv = command sys.path.insert(0, os.path.dirname(os.path.realpath(prog))) - execfile(prog, globals_cpy) + execfile(prog) def main(): @@ -245,11 +244,13 @@ def main(): or command[0].endswith('/python3')): del command[0] - # Run the user script / command. + # Run the user script / command. Explicitly catch Exception and + # KeyboardInterrupt rather than BaseException, since we don't want to + # catch SystemExit. try: _SAVEFRAME_LOGGER.info(f"Executing the program: {command_string!a}") run_program(command) - except Exception as err: + except (Exception, KeyboardInterrupt) as err: _SAVEFRAME_LOGGER.info( f"Saving frames and metadata info for the exception: {err!a}") # Save the frames and metadata info to the file. diff --git a/lib/python/pyflyby/_saveframe.py b/lib/python/pyflyby/_saveframe.py index 7a3c64bb..327df71b 100644 --- a/lib/python/pyflyby/_saveframe.py +++ b/lib/python/pyflyby/_saveframe.py @@ -8,6 +8,8 @@ from __future__ import annotations from contextlib import contextmanager +from dataclasses import dataclass +from enum import StrEnum import inspect import keyword import linecache @@ -36,6 +38,46 @@ """ DEFAULT_FILENAME = 'saveframe.pkl' + +@dataclass +class ExceptionInfo: + """ + A dataclass to store the exception info. + """ + exception_string: str + exception_full_string: str + exception_class_name: str + exception_class_qualname: str + exception_object: object + traceback: list + + +@dataclass +class FrameMetadata: + """ + A dataclass to store a frame's metadata. + """ + frame_index: int + filename: str + lineno: int + function_name: str + function_qualname: str + function_object: bytes + module_name: str + code: str + frame_identifier: str + + +class FrameFormat(StrEnum): + """ + Enum class to store the different formats supported by the `frames` argument + in the `saveframe` utility. See the doc of `saveframe` for more info. + """ + NUM = "NUM" + LIST = "LIST" + RANGE = "RANGE" + + def _get_saveframe_logger(): """ Get the logger used for the saveframe utility. @@ -91,25 +133,24 @@ def _get_exception_info(exception_obj): :param exception_obj: The exception raised by the user's code. :return: - A dict containing all the required information. It contains - 'exception_full_string', 'exception_object', 'exception_string', - 'exception_class_name', 'exception_class_qualname' and 'traceback'. - """ - exception_info = {} - exception_info['exception_full_string'] = ( - f'{exception_obj.__class__.__name__}: {exception_obj}') - exception_info['exception_object'] = exception_obj - exception_info['exception_string'] = str(exception_obj) - exception_info['exception_class_name'] = exception_obj.__class__.__name__ - exception_info['exception_class_qualname'] = exception_obj.__class__.__qualname__ + An `ExceptionInfo` object. + """ try: - exception_info['traceback'] = ( + tb = ( traceback.format_exception( type(exception_obj), exception_obj, exception_obj.__traceback__)) except Exception as err: _SAVEFRAME_LOGGER.warning( f"Error while formatting the traceback. Error: {err!a}") - exception_info['traceback'] = "Traceback couldn't be formatted" + tb = "Traceback couldn't be formatted" + exception_info = ExceptionInfo( + exception_string=str(exception_obj), + exception_full_string=f'{exception_obj.__class__.__name__}: {exception_obj}', + exception_class_name=exception_obj.__class__.__name__, + exception_class_qualname=exception_obj.__class__.__qualname__, + exception_object=exception_obj, + traceback=tb + ) return exception_info @@ -267,32 +308,35 @@ def _get_frame_metadata(frame_idx, frame_obj): :param frame_obj: The frame object for which to get the metadata. :return: - A dict containing all the required metadata. The metadata returned is: - 'frame_index', 'filename', 'lineno', 'function_name', 'function_qualname', - 'function_object', 'module_name', 'code' and 'frame_identifier'. - """ - # Mapping that stores all the frame's metadata. - frame_metadata = {} - frame_metadata['frame_index'] = frame_idx - frame_metadata['filename'] = frame_obj.f_code.co_filename - frame_metadata['lineno'] = frame_obj.f_lineno - frame_metadata['function_name'] = frame_obj.f_code.co_name - frame_metadata['function_qualname'] = frame_obj.f_code.co_qualname + A `FrameMetadata` object. + """ frame_function_object = _get_frame_function_object(frame_obj) try: - pickled_function = pickle.dumps(frame_function_object, protocol=PICKLE_PROTOCOL) + if isinstance(frame_function_object, str): + # Function object couldn't be found. + pickled_function = frame_function_object + else: + pickled_function = pickle.dumps( + frame_function_object, protocol=PICKLE_PROTOCOL) except Exception as err: _SAVEFRAME_LOGGER.info( f"Cannot pickle the function object for the frame: " f"{_get_frame_repr(frame_obj)}. Error: {err!a}") - frame_metadata['function_object'] = "Function object not pickleable" - else: - frame_metadata['function_object'] = pickled_function - frame_metadata['module_name'] = _get_frame_module_name(frame_obj) - frame_metadata['code'] = _get_frame_code_line(frame_obj) - frame_metadata['frame_identifier'] = ( - f"{frame_metadata['filename']},{frame_metadata['lineno']}," - f"{frame_metadata['function_name']}") + pickled_function = "Function object not pickleable" + # Object that stores all the frame's metadata. + frame_metadata = FrameMetadata( + frame_index=frame_idx, + filename=frame_obj.f_code.co_filename, + lineno=frame_obj.f_lineno, + function_name=frame_obj.f_code.co_name, + function_qualname=frame_obj.f_code.co_qualname, + function_object=pickled_function, + module_name=_get_frame_module_name(frame_obj), + code=_get_frame_code_line(frame_obj), + frame_identifier=( + f"{frame_obj.f_code.co_filename},{frame_obj.f_lineno}," + f"{frame_obj.f_code.co_name}") + ) return frame_metadata def _get_all_matching_frames(frame, all_frames): @@ -351,7 +395,7 @@ def _get_frames_to_save(frames, all_frames): # No frame passed by the user, return the first frame from the bottom # of the stack trace. return [(1, all_frames[0])] - elif frame_type == "num": + elif frame_type == FrameFormat.NUM: if len(all_frames) < frames: _SAVEFRAME_LOGGER.info( f"Number of frames to dump are {frames}, but there are only " @@ -359,10 +403,10 @@ def _get_frames_to_save(frames, all_frames): f"all the frames.") frames = len(all_frames) return [(idx+1, all_frames[idx]) for idx in range(frames)] - elif frame_type == "list": + elif frame_type == FrameFormat.LIST: for frame in frames: filtered_frames.extend(_get_all_matching_frames(frame, all_frames)) - elif frame_type == "range": + elif frame_type == FrameFormat.RANGE: # Handle 'first_frame..last_frame' and 'first_frame..' formats. # Find all the matching frames for the first_frame and last_frame. first_matching_frames = _get_all_matching_frames(frames[0], all_frames) @@ -491,12 +535,12 @@ def _save_frames_and_exception_info_to_file( _SAVEFRAME_LOGGER.info( f"Getting required info for the frame: {_get_frame_repr(frame_obj)}") frames_and_exception_info[frame_idx] = _get_frame_metadata( - frame_idx, frame_obj) + frame_idx, frame_obj).__dict__ frames_and_exception_info[frame_idx]['variables'] = ( _get_frame_local_variables_data(frame_obj, variables, exclude_variables)) _SAVEFRAME_LOGGER.info("Getting exception metadata info.") - frames_and_exception_info.update(_get_exception_info(exception_obj)) + frames_and_exception_info.update(_get_exception_info(exception_obj).__dict__) _SAVEFRAME_LOGGER.info(f"Saving the complete data in the file: {filename!a}") with _open_file(filename, 'wb') as f: pickle.dump(frames_and_exception_info, f, protocol=PICKLE_PROTOCOL) @@ -615,9 +659,9 @@ def _validate_frames(frames, utility): 2. The format of the ``frames``: - None if ``frames`` is None. - - For cases 1 and 2, the format is 'list'. - - For cases 3 and 4, the format is 'range'. - - For case 5, the format is 'num'. + - For cases 1 and 2, the format is `FrameFormat.LIST`. + - For cases 3 and 4, the format is `FrameFormat.RANGE`. + - For case 5, the format is `FrameFormat.NUM`. """ if frames is None: _SAVEFRAME_LOGGER.info( @@ -628,7 +672,7 @@ def _validate_frames(frames, utility): _SAVEFRAME_LOGGER.info(f"Validating frames: {frames!a}") try: # Handle frames as an integer. - return int(frames), "num" + return int(frames), FrameFormat.NUM except (ValueError, TypeError): pass # Boolean to denote if the `frames` parameter is passed in the range format. @@ -663,8 +707,7 @@ def _validate_frames(frames, utility): for idx, frame in enumerate(all_frames): frame_parts = frame.split(':') # Handle 'first_frame..' format (case 4.). - if (idx == 1 and len(frame_parts) == 1 and frame_parts[0] == '' and - is_range is True): + if idx == 1 and len(frame_parts) == 1 and frame_parts[0] == '' and is_range: parsed_frames.append(frame_parts) break if len(frame_parts) != 3: @@ -686,7 +729,7 @@ def _validate_frames(frames, utility): f"converted to an integer.") parsed_frames.append(frame_parts) - return parsed_frames, "range" if is_range else "list" + return parsed_frames, FrameFormat.RANGE if is_range else FrameFormat.LIST def _is_variable_name_valid(name): @@ -718,7 +761,7 @@ def _validate_variables(variables, utility): or the ``pyflyby/bin/saveframe`` script. See `_validate_saveframe_arguments` for more info. :return: - A list of filtered variables post validation. + A tuple of filtered variables post validation. """ if variables is None: return @@ -729,9 +772,13 @@ def _validate_variables(variables, utility): f"pass multiple variable names, pass a list/tuple of names like " f"{variables.split(',')} rather than a comma separated string of names.") if isinstance(variables, (list, tuple)): - all_variables = variables + all_variables = tuple(variables) + elif isinstance(variables, str): + all_variables = tuple(variable.strip() for variable in variables.split(',')) else: - all_variables = [variable.strip() for variable in variables.split(',')] + raise TypeError( + f"Variables '{variables}' must be of type list, tuple or string (for a " + f"single variable), not '{type(variables)}'") invalid_variable_names = [variable for variable in all_variables if not _is_variable_name_valid(variable)] if invalid_variable_names: @@ -739,8 +786,8 @@ def _validate_variables(variables, utility): f"Invalid variable names: {invalid_variable_names}. Skipping these " f"variables and continuing.") # Filter out invalid variables. - all_variables = [variable for variable in all_variables - if variable not in invalid_variable_names] + all_variables = tuple(variable for variable in all_variables + if variable not in invalid_variable_names) return all_variables @@ -798,13 +845,13 @@ def saveframe(filename=None, frames=None, variables=None, exclude_variables=None Usage: -------------------------------------------------------------------------- If you have a piece of code that is currently failing due to an issue - originating from another team's code, and you cannot share your private + originating from upstream code, and you cannot share your private code as a reproducer, use this function to save relevant information to a file. While in an interactive session such as IPython, Jupyter Notebook, or a debugger (pdb/ipdb), you can call this function after your code raised - an error to capture and save error frames specific to the other team's - codebase. Share the generated file with the other team, enabling them to - reproduce and diagnose the issue independently. + an error to capture and save error frames specific to the upstream codebase + Share the generated file with the upstream team, enabling them to reproduce + and diagnose the issue independently. Information saved in the file: -------------------------------------------------------------------------- @@ -975,4 +1022,4 @@ def saveframe(filename=None, frames=None, variables=None, exclude_variables=None _save_frames_and_exception_info_to_file( filename=filename, frames=frames, variables=variables, exclude_variables=exclude_variables, exception_obj=exception_obj) - return filename \ No newline at end of file + return filename diff --git a/tests/test_saveframe.py b/tests/test_saveframe.py index 7b9fe5b0..13a3a499 100644 --- a/tests/test_saveframe.py +++ b/tests/test_saveframe.py @@ -106,7 +106,7 @@ def frames_metadata_checker(tmpdir, pkg_name, filename): assert data[3]["code"] == 'obj.func2()' assert data[3]["frame_index"] == 3 assert data[3]["filename"] == str(tmpdir / pkg_name / "mod1.py") - assert data[3]["lineno"] == 7 + assert data[3]["lineno"] == 9 assert data[3]["function_name"] == "func2" assert data[3]["function_qualname"] == "func2" assert data[3]["module_name"] == f"{pkg_name}.mod1" @@ -116,7 +116,7 @@ def frames_metadata_checker(tmpdir, pkg_name, filename): assert data[4]["code"] == 'func2()' assert data[4]["frame_index"] == 4 assert data[4]["filename"] == str(tmpdir / pkg_name / "mod1.py") - assert data[4]["lineno"] == 12 + assert data[4]["lineno"] == 14 assert data[4]["function_name"] == "func1" assert data[4]["function_qualname"] == "func1" assert data[4]["module_name"] == f"{pkg_name}.mod1" @@ -134,7 +134,37 @@ def frames_metadata_checker(tmpdir, pkg_name, filename): f"{data[5]['filename']},{data[5]['lineno']},{data[5]['function_name']}") -def frames_local_variables_checker(tmpdir, pkg_name, filename): +def frames_metadata_checker_for_keyboard_interrupt(tmpdir, pkg_name, filename): + """ + Check if the metadata of the frames is correctly written in the ``filename``, + when KeyboardInterrupt exception is raised. + """ + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2} + + assert data[1]["code"] == 'os.kill(os.getpid(), signal.SIGINT)' + assert data[1]["frame_index"] == 1 + assert data[1]["filename"] == str( + tmpdir / pkg_name / "mod1.py") + assert data[1]["lineno"] == 19 + assert data[1]["function_name"] == "interrupt_func" + assert data[1]["function_qualname"] == "interrupt_func" + assert data[1]["module_name"] == f"{pkg_name}.mod1" + assert data[1]["frame_identifier"] == ( + f"{data[1]['filename']},{data[1]['lineno']},{data[1]['function_name']}") + + assert data[2]["code"] == 'interrupt_func()' + assert data[2]["frame_index"] == 2 + assert data[2]["filename"] == str(tmpdir / pkg_name / "__init__.py") + assert data[2]["lineno"] == 22 + assert data[2]["function_name"] == "init_func4" + assert data[2]["function_qualname"] == "init_func4" + assert data[2]["module_name"] == f"{pkg_name}" + assert data[2]["frame_identifier"] == ( + f"{data[2]['filename']},{data[2]['lineno']},{data[2]['function_name']}") + + +def frames_local_variables_checker(pkg_name, filename): """ Check if the local variables of the frames are correctly written in the ``filename``. @@ -169,6 +199,22 @@ def frames_local_variables_checker(tmpdir, pkg_name, filename): assert pickle.loads(data[5]['variables']['var2']) == 'blah' +def frames_local_variables_checker_for_keyboard_interrupt(filename): + """ + Check if the local variables of the frames are correctly written in the + ``filename``, when KeyboardInterrupt exception is raised. + """ + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2} + + assert set(data[1]['variables'].keys()) == {'interrupt_var1', 'interrupt_var2'} + assert pickle.loads(data[1]['variables']['interrupt_var1']) == 'foo bar' + + assert set(data[2]['variables'].keys()) == {'var1', 'var2'} + assert pickle.loads(data[2]['variables']['var1']) == 'init_func4' + assert pickle.loads(data[2]['variables']['var2']) == [3, 4] + + def exception_info_checker(filename): """ Check if the exception info is correctly written in the ``filename``. @@ -184,6 +230,22 @@ def exception_info_checker(filename): assert data['exception_string'] == 'Error is raised' +def exception_info_checker_for_keyboard_interrupt(filename): + """ + Check if the exception info is correctly written in the ``filename``, + when KeyboardInterrupt exception is raised. + """ + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys + assert data['exception_class_name'] == 'KeyboardInterrupt' + assert data['exception_class_qualname'] == 'KeyboardInterrupt' + assert data['exception_full_string'] == 'KeyboardInterrupt: ' + assert isinstance(data['exception_object'], KeyboardInterrupt) + # Traceback shouldn't be pickled for security reasons. + assert data['exception_object'].__traceback__ == None + assert data['exception_string'] == '' + + def create_pkg(tmpdir): """ Create a pacakge with multiple nested sub-packages and modules in ``tmpdir``. @@ -191,7 +253,7 @@ def create_pkg(tmpdir): pkg_name = f"saveframe_{int(random.random() * 10**9)}" os.mkdir(str(tmpdir / pkg_name)) writetext(tmpdir / pkg_name / "__init__.py", f""" - from {pkg_name}.mod1 import func1 + from {pkg_name}.mod1 import func1, interrupt_func def init_func1(): var1 = 3 var2 = 'blah' @@ -207,8 +269,15 @@ def init_func3(): func1() except ValueError as err: raise TypeError("Chained exception") from err + + def init_func4(): + var1 = 'init_func4' + var2 = [3, 4] + interrupt_func() """) writetext(tmpdir / pkg_name / "mod1.py", f""" + import os + import signal from {pkg_name}.pkg1.mod2 import mod2_cls def func2(): var1 = "func2" @@ -220,6 +289,11 @@ def func1(): var1 = [4, 5, 2] func1_var2 = 4.56 func2() + + def interrupt_func(): + interrupt_var1 = 'foo bar' + interrupt_var2 = 3.4 + os.kill(os.getpid(), signal.SIGINT) """) os.mkdir(str(tmpdir / pkg_name / "pkg1")) writetext(tmpdir / pkg_name / "pkg1" / "__init__.py", "") @@ -414,6 +488,21 @@ def test_saveframe_invalid_variables_2(tmpdir, caplog): delattr(sys, "last_value") +def test_saveframe_invalid_variables_3(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(TypeError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + variables=1) + err_msg = ("Variables '1' must be of type list, tuple or string (for a single " + "variable), not ''") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + def test_saveframe_invalid_exclude_variables_1(tmpdir): pkg_name = create_pkg(tmpdir) try: @@ -445,6 +534,21 @@ def test_saveframe_invalid_exclude_variables_2(tmpdir, caplog): delattr(sys, "last_value") +def test_saveframe_invalid_exclude_variables_3(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func1; init_func1()") + except ValueError as err: + sys.last_value = err + with pytest.raises(TypeError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + exclude_variables=1) + err_msg = ("Variables '1' must be of type list, tuple or string (for a single " + "variable), not ''") + assert str(err.value) == err_msg + delattr(sys, "last_value") + + def test_saveframe_no_error_raised(tmpdir): if hasattr(sys, "last_value"): delattr(sys, "last_value") @@ -656,7 +760,7 @@ def test_saveframe_local_variables_data(tmpdir): filename = saveframe( filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) - frames_local_variables_checker(tmpdir, pkg_name, filename) + frames_local_variables_checker(pkg_name, filename) delattr(sys, "last_value") @@ -696,6 +800,46 @@ def test_saveframe_chained_exceptions(tmpdir): assert data[1]["frame_identifier"] == ( f"{data[1]['filename']},{data[1]['lineno']},{data[1]['function_name']}") assert len(set(data.keys()) - exception_info_keys) == 5 + delattr(sys, "last_value") + + +def test_keyboard_interrupt_frame_metadata(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func4; init_func4()") + except KeyboardInterrupt as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=2) + frames_metadata_checker_for_keyboard_interrupt(tmpdir, pkg_name, filename) + delattr(sys, "last_value") + + +def test_keyboard_interrupt_local_variables_data(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func4; init_func4()") + except KeyboardInterrupt as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=2) + frames_local_variables_checker_for_keyboard_interrupt(filename) + delattr(sys, "last_value") + + +def test_keyboard_interrupt_exception_info(tmpdir): + pkg_name = create_pkg(tmpdir) + try: + exec(f"from {pkg_name} import init_func4; init_func4()") + except KeyboardInterrupt as err: + sys.last_value = err + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=0) + exception_info_checker_for_keyboard_interrupt(filename) + delattr(sys, "last_value") def test_saveframe_cmdline_no_exception(): @@ -788,7 +932,6 @@ def test_saveframe_cmdline_frame_metadata(tmpdir): f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " f"init_func1; init_func1()"] run_command(command) - frames_metadata_checker(tmpdir, pkg_name, filename) @@ -805,8 +948,7 @@ def test_saveframe_cmdline_local_variables_data(tmpdir): BIN_DIR + "/saveframe", "--filename", filename, "--frames", "5", "python", str(tmp_mod)] run_command(command) - - frames_local_variables_checker(tmpdir, pkg_name, filename) + frames_local_variables_checker(pkg_name, filename) def test_saveframe_cmdline_exception_info(tmpdir): @@ -818,5 +960,44 @@ def test_saveframe_cmdline_exception_info(tmpdir): f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " f"init_func1; init_func1()"] run_command(command) - exception_info_checker(filename) + + +def test_saveframe_cmdline_keyboard_interrupt_frame_metadata(tmpdir): + pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR+"/saveframe", "--filename", filename, "--frames", "2", + "python", "-c", + f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " + f"init_func4; init_func4()"] + run_command(command) + frames_metadata_checker_for_keyboard_interrupt(tmpdir, pkg_name, filename) + + +def test_saveframe_cmdline_keyboard_interrupt_local_variables_data(tmpdir): + pkg_name = create_pkg(tmpdir) + tmp_mod = writetext(tmpdir / f"tmp_mod_{get_random()}.py", f""" + import sys + sys.path.append('{tmpdir}') + from {pkg_name} import init_func4 + init_func4() + """) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR + "/saveframe", "--filename", filename, "--frames", "2", + "python", str(tmp_mod)] + run_command(command) + frames_local_variables_checker_for_keyboard_interrupt(filename) + + +def test_saveframe_cmdline_keyboard_interrupt_exception_info(tmpdir): + pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + command = [ + BIN_DIR + "/saveframe", "--filename", filename, "--frames", "0", + "python", "-c", + f"import sys; sys.path.append('{tmpdir}'); from {pkg_name} import " + f"init_func4; init_func4()"] + run_command(command) + exception_info_checker_for_keyboard_interrupt(filename) From 4d52269544d98dc1b23a98efc89e81ee652d1e46 Mon Sep 17 00:00:00 2001 From: Sachin Saharan Date: Mon, 30 Sep 2024 13:21:55 +0530 Subject: [PATCH 3/4] Make changes python 3.10 compatible --- lib/python/pyflyby/_saveframe.py | 23 ++++++++++++++++------- tests/test_saveframe.py | 19 +++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/python/pyflyby/_saveframe.py b/lib/python/pyflyby/_saveframe.py index 327df71b..56a13f07 100644 --- a/lib/python/pyflyby/_saveframe.py +++ b/lib/python/pyflyby/_saveframe.py @@ -9,7 +9,7 @@ from contextlib import contextmanager from dataclasses import dataclass -from enum import StrEnum +from enum import Enum import inspect import keyword import linecache @@ -68,7 +68,7 @@ class FrameMetadata: frame_identifier: str -class FrameFormat(StrEnum): +class FrameFormat(Enum): """ Enum class to store the different formats supported by the `frames` argument in the `saveframe` utility. See the doc of `saveframe` for more info. @@ -154,6 +154,15 @@ def _get_exception_info(exception_obj): return exception_info +def _get_qualname(frame): + """ + Get fully qualified name of the function for the ``frame``. + + In python 3.10, ``co_qualname`` attribute is not present, so use ``co_name``. + """ + return (frame.f_code.co_qualname if hasattr(frame.f_code, "co_qualname") + else frame.f_code.co_name) + def _get_frame_repr(frame): """ Construct repr for the ``frame``. This is used in the info messages. @@ -164,7 +173,7 @@ def _get_frame_repr(frame): The string f'File: {filename}, Line: {lineno}, Function: {function_qualname}' """ return (f"'File: {frame.f_code.co_filename}, Line: {frame.f_lineno}, " - f"Function: {frame.f_code.co_qualname}'") + f"Function: {_get_qualname(frame)}'") def _get_frame_local_variables_data(frame, variables, exclude_variables): @@ -219,7 +228,7 @@ def _get_frame_function_object(frame): The function object from which the ``frame`` is originating. """ func_name = frame.f_code.co_name - func_qualname = frame.f_code.co_qualname + func_qualname = _get_qualname(frame) info_msg = f"Can't get function object for frame: {_get_frame_repr(frame)}" return_msg = "Function object not found" # The function is most-likely either a local function or a class method. @@ -329,7 +338,7 @@ def _get_frame_metadata(frame_idx, frame_obj): filename=frame_obj.f_code.co_filename, lineno=frame_obj.f_lineno, function_name=frame_obj.f_code.co_name, - function_qualname=frame_obj.f_code.co_qualname, + function_qualname=_get_qualname(frame_obj), function_object=pickled_function, module_name=_get_frame_module_name(frame_obj), code=_get_frame_code_line(frame_obj), @@ -369,7 +378,7 @@ def _get_all_matching_frames(frame, all_frames): if lineno and frame_obj.f_lineno != lineno: continue if (func_name and - func_name not in (frame_obj.f_code.co_name, frame_obj.f_code.co_qualname)): + func_name not in (frame_obj.f_code.co_name, _get_qualname(frame_obj))): continue all_matching_frames.append((idx+1, frame_obj)) return all_matching_frames @@ -1012,7 +1021,7 @@ def saveframe(filename=None, frames=None, variables=None, exclude_variables=None if interactive_session_obj and hasattr(interactive_session_obj, 'curframe'): current_frame = interactive_session_obj.curframe frames = (f"{current_frame.f_code.co_filename}:{current_frame.f_lineno}:" - f"{current_frame.f_code.co_qualname}") + f"{_get_qualname(current_frame)}") _SAVEFRAME_LOGGER.info("Validating arguments passed.") filename, frames, variables, exclude_variables = _validate_saveframe_arguments( diff --git a/tests/test_saveframe.py b/tests/test_saveframe.py index 13a3a499..cd9ecc12 100644 --- a/tests/test_saveframe.py +++ b/tests/test_saveframe.py @@ -13,6 +13,7 @@ from pyflyby import Filename, saveframe +VERSION_INFO = sys.version_info PYFLYBY_HOME = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) BIN_DIR = os.path.join(PYFLYBY_HOME, "bin") @@ -98,7 +99,9 @@ def frames_metadata_checker(tmpdir, pkg_name, filename): assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") assert data[2]["lineno"] == 10 assert data[2]["function_name"] == "func2" - assert data[2]["function_qualname"] == "mod2_cls.func2" + assert ( + data[2]["function_qualname"] == + "func2" if VERSION_INFO < (3, 11) else "mod2_cls.func2") assert data[2]["module_name"] == f"{pkg_name}.pkg1.mod2" assert data[2]["frame_identifier"] == ( f"{data[2]['filename']},{data[2]['lineno']},{data[2]['function_name']}") @@ -570,7 +573,7 @@ def test_saveframe_frame_format_1(tmpdir): sys.last_value = err filename = str(tmpdir / f"saveframe_{get_random()}.pkl") # Format: 'filename:line_no:func_name' - saveframe(filename=filename, frames=f"pkg1/mod2.py:10:func2") + saveframe(filename=filename, frames="pkg1/mod2.py:10:func2") data = load_pkl(filename) assert set(data.keys()) == exception_info_keys | {2} assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") @@ -604,7 +607,9 @@ def test_saveframe_frame_format_2(tmpdir): data = load_pkl(filename) assert set(data.keys()) == exception_info_keys | {2, 5} assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") - assert data[2]["function_qualname"] == "mod2_cls.func2" + assert ( + data[2]["function_qualname"] == + "func2" if VERSION_INFO < (3, 11) else "mod2_cls.func2") assert data[5]["filename"] == str(tmpdir / pkg_name / "__init__.py") assert data[5]["function_qualname"] == "init_func1" @@ -702,13 +707,11 @@ def test_saveframe_exclude_variables(tmpdir, caplog): assert set(data[3]["variables"].keys()) == {"obj"} assert set(data[4]["variables"].keys()) == {"func1_var2"} assert set(data[5]["variables"].keys()) == set() - + qualname = "func2" if VERSION_INFO < (3, 11) else "mod2_cls.func2" warning_msg = ( f"Cannot pickle variable: 'var3' for frame: 'File: {str(tmpdir)}/{pkg_name}" - "/pkg1/mod2.py, Line: 10, Function: mod2_cls.func2'. Error: TypeError" - "(\"cannot pickle 'function' object\"). Skipping this variable and " - "continuing.") - assert warning_msg in log_messages + f"/pkg1/mod2.py, Line: 10, Function: {qualname}'.") + assert warning_msg in "\n".join(log_messages) delattr(sys, "last_value") From 455c1499953f9290a4fb1e0b91843e7d98f59331 Mon Sep 17 00:00:00 2001 From: Sachin Saharan Date: Thu, 3 Oct 2024 18:08:20 +0530 Subject: [PATCH 4/4] Addressed review comments - Removed f-strings from logger's info and warning - Use sys.last_exc for python 3.12+ - Added some examples in the doc of saveframe function and the script --- bin/saveframe | 44 ++- lib/python/pyflyby/_saveframe.py | 126 +++++--- setup.py | 1 + tests/test_saveframe.py | 496 ++++++++++++++----------------- 4 files changed, 349 insertions(+), 318 deletions(-) diff --git a/bin/saveframe b/bin/saveframe index a6c07efb..903abf26 100755 --- a/bin/saveframe +++ b/bin/saveframe @@ -55,11 +55,43 @@ NOTE: Example Usage: - $ saveframe + Let's say your script / command is raising an error with the following traceback: + + File "dir/__init__.py", line 6, in init_func1 + func1() + File "dir/mod1.py", line 14, in func1 + func2() + File "dir/mod1.py", line 9, in func2 + obj.func2() + File "dir/pkg1/mod2.py", line 10, in func2 + func3() + File "dir/pkg1/pkg2/mod3.py", line 6, in func3 + raise ValueError("Error is raised") + ValueError: Error is raised + + => To save the last frame (the error frame) in file '/path/to/file', use: $ saveframe --filename=/path/to/file - $ saveframe --frames=frames_to_save - $ saveframe --variables=local_variables_to_include - $ saveframe --exclude_variables=local_variables_to_exclude + + => To save a specific frame like `File "dir/mod1.py", line 9, in func2`, use: + $ saveframe --filename=/path/to/file --frames=mod1.py:9:func2 + + => To save the last 3 frames from the bottom, use: + $ saveframe --frames=3 + + => To save all the frames from 'mod1.py' and 'mod2.py' files, use: + $ saveframe --filename=/path/to/file --frames=mod1.py::,mod2.py:: + + => To save a range of frames from 'mod1.py' to 'mod3.py', use: + $ saveframe --frames=mod1.py::..mod3.py:: + + => To save a range of frames from '__init__.py' till the last frame, use: + $ saveframe --frames=__init__.py::.. + + => To only save local variables 'var1' and 'var2' from the frames, use: + $ saveframe --frames=frames_to_save --variables=var1,var2 + + => To exclude local variables 'var1' and 'var2' from the frames, use: + $ saveframe --frames=frames_to_save --exclude_variables=var1,var2 For interactive use cases, checkout pyflyby.saveframe function. """ @@ -248,11 +280,11 @@ def main(): # KeyboardInterrupt rather than BaseException, since we don't want to # catch SystemExit. try: - _SAVEFRAME_LOGGER.info(f"Executing the program: {command_string!a}") + _SAVEFRAME_LOGGER.info("Executing the program: %a", command_string) run_program(command) except (Exception, KeyboardInterrupt) as err: _SAVEFRAME_LOGGER.info( - f"Saving frames and metadata info for the exception: {err!a}") + "Saving frames and metadata info for the exception: %a", err) # Save the frames and metadata info to the file. _save_frames_and_exception_info_to_file( filename=filename, frames=frames, variables=variables, diff --git a/lib/python/pyflyby/_saveframe.py b/lib/python/pyflyby/_saveframe.py index 56a13f07..8ccc313a 100644 --- a/lib/python/pyflyby/_saveframe.py +++ b/lib/python/pyflyby/_saveframe.py @@ -141,7 +141,7 @@ def _get_exception_info(exception_obj): type(exception_obj), exception_obj, exception_obj.__traceback__)) except Exception as err: _SAVEFRAME_LOGGER.warning( - f"Error while formatting the traceback. Error: {err!a}") + "Error while formatting the traceback. Error: %a", err) tb = "Traceback couldn't be formatted" exception_info = ExceptionInfo( exception_string=str(exception_obj), @@ -207,9 +207,9 @@ def _get_frame_local_variables_data(frame, variables, exclude_variables): all_local_variables[variable], protocol=PICKLE_PROTOCOL) except Exception as err: _SAVEFRAME_LOGGER.warning( - f"Cannot pickle variable: {variable!a} for frame: " - f"{_get_frame_repr(frame)}. Error: {err!a}. Skipping this " - f"variable and continuing.") + "Cannot pickle variable: %a for frame: %s. Error: %a. Skipping " + "this variable and continuing.", + variable, _get_frame_repr(frame), err) else: local_variables_to_save[variable] = pickled_value return local_variables_to_save @@ -280,13 +280,13 @@ def _get_frame_module_name(frame): frame_module = inspect.getmodule(frame) if frame_module is not None: return frame_module.__name__ - _SAVEFRAME_LOGGER.info(f"No module found for the frame: " - f"{_get_frame_repr(frame)}") + _SAVEFRAME_LOGGER.info( + "No module found for the frame: %s", _get_frame_repr(frame)) return "Module name not found" except Exception as err: _SAVEFRAME_LOGGER.warning( - f"Module name couldn't be found for the frame: " - f"{_get_frame_repr(frame)}. Error: {err!a}") + "Module name couldn't be found for the frame: %s. Error: %a", + _get_frame_repr(frame), err) return "Module name not found" @@ -329,8 +329,8 @@ def _get_frame_metadata(frame_idx, frame_obj): frame_function_object, protocol=PICKLE_PROTOCOL) except Exception as err: _SAVEFRAME_LOGGER.info( - f"Cannot pickle the function object for the frame: " - f"{_get_frame_repr(frame_obj)}. Error: {err!a}") + "Cannot pickle the function object for the frame: %s. Error: %a", + _get_frame_repr(frame_obj), err) pickled_function = "Function object not pickleable" # Object that stores all the frame's metadata. frame_metadata = FrameMetadata( @@ -407,9 +407,9 @@ def _get_frames_to_save(frames, all_frames): elif frame_type == FrameFormat.NUM: if len(all_frames) < frames: _SAVEFRAME_LOGGER.info( - f"Number of frames to dump are {frames}, but there are only " - f"{len(all_frames)} frames in the error stack. So dumping " - f"all the frames.") + "Number of frames to dump are %s, but there are only %s frames " + "in the error stack. So dumping all the frames.", + frames, len(all_frames)) frames = len(all_frames) return [(idx+1, all_frames[idx]) for idx in range(frames)] elif frame_type == FrameFormat.LIST: @@ -538,11 +538,12 @@ def _save_frames_and_exception_info_to_file( all_frames = _get_all_frames_from_exception_obj(exception_obj) # Take out the frame objects we want to save as per 'frames'. frames_to_save = _get_frames_to_save(frames, all_frames) - _SAVEFRAME_LOGGER.info(f"Number of frames that'll be saved: {len(frames_to_save)}") + _SAVEFRAME_LOGGER.info( + "Number of frames that'll be saved: %s", len(frames_to_save)) for frame_idx, frame_obj in frames_to_save: _SAVEFRAME_LOGGER.info( - f"Getting required info for the frame: {_get_frame_repr(frame_obj)}") + "Getting required info for the frame: %s", _get_frame_repr(frame_obj)) frames_and_exception_info[frame_idx] = _get_frame_metadata( frame_idx, frame_obj).__dict__ frames_and_exception_info[frame_idx]['variables'] = ( @@ -550,7 +551,7 @@ def _save_frames_and_exception_info_to_file( _SAVEFRAME_LOGGER.info("Getting exception metadata info.") frames_and_exception_info.update(_get_exception_info(exception_obj).__dict__) - _SAVEFRAME_LOGGER.info(f"Saving the complete data in the file: {filename!a}") + _SAVEFRAME_LOGGER.info("Saving the complete data in the file: %a", filename) with _open_file(filename, 'wb') as f: pickle.dump(frames_and_exception_info, f, protocol=PICKLE_PROTOCOL) _SAVEFRAME_LOGGER.info("Done!!") @@ -594,10 +595,11 @@ def _validate_filename(filename, utility): if filename is None: filename = os.path.abspath(DEFAULT_FILENAME) _SAVEFRAME_LOGGER.info( - f"Filename is not passed explicitly using the " - f"{'`filename` parameter' if utility == 'function' else '--filename argument'}. " - f"The frame info will be saved in the file: {filename!a}.") - _SAVEFRAME_LOGGER.info(f"Validating filename: {filename!a}") + "Filename is not passed explicitly using the %s. The frame info will " + "be saved in the file: %a.", + '`filename` parameter' if utility == 'function' else '--filename argument', + filename) + _SAVEFRAME_LOGGER.info("Validating filename: %a", filename) # Resolve any symlinks. filename = os.path.realpath(filename) if os.path.islink(filename): @@ -607,13 +609,11 @@ def _validate_filename(filename, utility): f"pass a different filename.") if os.path.exists(filename): _SAVEFRAME_LOGGER.info( - f"File {filename!a} already exists. This run will " - f"overwrite the file.") + "File %a already exists. This run will overwrite the file.", filename) parent_dir = os.path.dirname(filename) # Check if the parent directory and the ancestors are world traversable. # Log a warning if not. Raise an error if the parent or any ancestor # directory doesn't exist. - is_parent_and_ancestors_world_traversable = True try: is_parent_and_ancestors_world_traversable = ( _is_dir_and_ancestors_world_traversable(directory=parent_dir)) @@ -624,15 +624,15 @@ def _validate_filename(filename, utility): f"{filename!a}. Error: {err!a}") raise type(err)(msg) from None except OSError as err: + is_parent_and_ancestors_world_traversable = False _SAVEFRAME_LOGGER.warning( - f"Error while trying to determine if the parent directory: " - f"{parent_dir!a} and the ancestors are world traversable. " - f"Error: {err!a}") + "Error while trying to determine if the parent directory: %a and " + "the ancestors are world traversable. Error: %a", parent_dir, err) if not is_parent_and_ancestors_world_traversable: _SAVEFRAME_LOGGER.warning( - f"The parent directory {parent_dir!a} or an ancestor is not world " - f"traversable (i.e., the execute bit of one of the ancestors is 0). " - f"The filename {filename!a} might not be accessible by others.") + "The parent directory %a or an ancestor is not world traversable " + "(i.e., the execute bit of one of the ancestors is 0). The filename " + "%a might not be accessible by others.", parent_dir, filename) return filename @@ -674,11 +674,11 @@ def _validate_frames(frames, utility): """ if frames is None: _SAVEFRAME_LOGGER.info( - f"{'`frames` parameter' if utility == 'function' else '--frames argument'} " - f"is not passed explicitly. The first frame from the bottom will be " - f"saved by default.") + "%s is not passed explicitly. The first frame from the bottom will be " + "saved by default.", + '`frames` parameter' if utility == 'function' else '--frames argument') return None, None - _SAVEFRAME_LOGGER.info(f"Validating frames: {frames!a}") + _SAVEFRAME_LOGGER.info("Validating frames: %a", frames) try: # Handle frames as an integer. return int(frames), FrameFormat.NUM @@ -774,7 +774,7 @@ def _validate_variables(variables, utility): """ if variables is None: return - _SAVEFRAME_LOGGER.info(f"Validating variables: {variables!a}") + _SAVEFRAME_LOGGER.info("Validating variables: %a", variables) if isinstance(variables, str) and ',' in variables and utility == 'function': raise ValueError( f"Error while validating variables: {variables!a}. If you want to " @@ -792,8 +792,8 @@ def _validate_variables(variables, utility): if not _is_variable_name_valid(variable)] if invalid_variable_names: _SAVEFRAME_LOGGER.warning( - f"Invalid variable names: {invalid_variable_names}. Skipping these " - f"variables and continuing.") + "Invalid variable names: %s. Skipping these variables and continuing.", + invalid_variable_names) # Filter out invalid variables. all_variables = tuple(variable for variable in all_variables if variable not in invalid_variable_names) @@ -839,10 +839,11 @@ def _validate_saveframe_arguments( exclude_variables = _validate_variables(exclude_variables, utility) if not (variables or exclude_variables): _SAVEFRAME_LOGGER.info( - f"Neither {'`variables`' if utility == 'function' else '--variables'} " - f"nor {'`exclude_variables`' if utility == 'function' else '--exclude_variables'} " - f"{'parameter' if utility == 'function' else 'argument'} is passed. " - f"All the local variables from the frames will be saved.") + "Neither %s nor %s %s is passed. All the local variables from the " + "frames will be saved.", + '`variables`' if utility == 'function' else '--variables', + '`exclude_variables`' if utility == 'function' else '--exclude_variables', + 'parameter' if utility == 'function' else 'argument') return filename, frames, variables, exclude_variables @@ -928,6 +929,44 @@ def saveframe(filename=None, frames=None, variables=None, exclude_variables=None >> ipdb> saveframe(filename=/path/to/file, frames=frames_to_save, .. variables=local_variables_to_include, exclude_variables=local_variables_to_exclude) + # Let's say your code is raising an error with the following traceback: + + File "dir/__init__.py", line 6, in init_func1 + func1() + File "dir/mod1.py", line 14, in func1 + func2() + File "dir/mod1.py", line 9, in func2 + obj.func2() + File "dir/pkg1/mod2.py", line 10, in func2 + func3() + File "dir/pkg1/pkg2/mod3.py", line 6, in func3 + raise ValueError("Error is raised") + ValueError: Error is raised + + # To save the last frame (the error frame) in file '/path/to/file', use: + >> saveframe(filename='/path/to/file') + + # To save a specific frame like `File "dir/mod1.py", line 9, in func2`, use: + >> saveframe(filename='/path/to/file', frames='mod1.py:9:func2') + + # To save the last 3 frames from the bottom, use: + >> saveframe(frames=3) + + # To save all the frames from 'mod1.py' and 'mod2.py' files, use: + >> saveframe(filename='/path/to/file', frames=['mod1.py::', 'mod2.py::']) + + # To save a range of frames from 'mod1.py' to 'mod3.py', use: + >> saveframe(frames='mod1.py::..mod3.py::') + + # To save a range of frames from '__init__.py' till the last frame, use: + >> saveframe(frames='__init__.py::..') + + # To only save local variables 'var1' and 'var2' from the frames, use: + >> saveframe(frames=, variables=['var1', 'var2']) + + # To exclude local variables 'var1' and 'var2' from the frames, use: + >> saveframe(frames=, exclude_variables=['var1', 'var2']) + For non-interactive use cases (e.g., a failing script or command), checkout `pyflyby/bin/saveframe` script. @@ -1005,13 +1044,14 @@ def saveframe(filename=None, frames=None, variables=None, exclude_variables=None :return: The file path in which the frame info is saved. """ - if not hasattr(sys, 'last_value'): + if not ((sys.version_info < (3, 12) and hasattr(sys, 'last_value')) or + (sys.version_info >= (3, 12) and hasattr(sys, 'last_exc'))): raise RuntimeError( "No exception is raised currently for which to save the frames. " "Make sure that an uncaught exception is raised before calling the " "`saveframe` function.") # Get the latest exception raised. - exception_obj = sys.last_value + exception_obj = sys.last_value if sys.version_info < (3, 12) else sys.last_exc if frames is None: # Get the instance of the interactive session the user is currently in. @@ -1027,7 +1067,7 @@ def saveframe(filename=None, frames=None, variables=None, exclude_variables=None filename, frames, variables, exclude_variables = _validate_saveframe_arguments( filename, frames, variables, exclude_variables) _SAVEFRAME_LOGGER.info( - f"Saving frames and metadata for the exception: '{exception_obj!a}") + "Saving frames and metadata for the exception: %a", exception_obj) _save_frames_and_exception_info_to_file( filename=filename, frames=frames, variables=variables, exclude_variables=exclude_variables, exception_obj=exception_obj) diff --git a/setup.py b/setup.py index 9b8c179d..fa6e9959 100755 --- a/setup.py +++ b/setup.py @@ -200,6 +200,7 @@ def make_distribution(self): 'bin/pyflyby-diff', 'bin/reformat-imports', 'bin/replace-star-imports', + 'bin/saveframe', 'bin/tidy-imports', 'bin/transform-imports', ], diff --git a/tests/test_saveframe.py b/tests/test_saveframe.py index cd9ecc12..e9a9b6d1 100644 --- a/tests/test_saveframe.py +++ b/tests/test_saveframe.py @@ -76,6 +76,21 @@ def run_command(command): return result.stderr.decode('utf-8').strip().split('\n') +@contextmanager +def run_code_and_set_exception(code, exception): + try: + exec(code) + except exception as err: + if VERSION_INFO < (3, 12): + sys.last_value = err + else: + sys.last_exc = err + try: + yield + finally: + delattr(sys, "last_value" if VERSION_INFO < (3, 12) else "last_exc") + + def frames_metadata_checker(tmpdir, pkg_name, filename): """ Check if the metadata of the frames is correctly written in the ``filename``. @@ -325,236 +340,194 @@ def func3(): def test_saveframe_invalid_filename_1(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / pkg_name)) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / pkg_name)) err_msg = (f"{str(tmpdir / pkg_name)!a} is an already existing directory. " f"Please pass a different filename.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_filename_2(tmpdir): pkg_name = create_pkg(tmpdir) filename = str(tmpdir / f"saveframe_dir_{get_random()}" / f"saveframe_{get_random()}.pkl") - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(FileNotFoundError) as err: - saveframe(filename=filename) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(FileNotFoundError) as err: + saveframe(filename=filename) err_msg = (f"Error while saving the frames to the file: {filename!a}. Error: " f"FileNotFoundError(2, 'No such file or directory')") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_frames_1(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames="foo.py:") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="foo.py:") err_msg = ("Error while validating frame: 'foo.py:'. The correct syntax for " "a frame is 'file_regex:line_no:function_name' but frame 'foo.py:' " "contains 1 ':'.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_frames_2(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=":12:func1") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=":12:func1") err_msg = ("Error while validating frame: ':12:func1'. The filename / file " "regex must be passed in a frame.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_frames_3(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames="file.py:12:func1,file1.py::") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="file.py:12:func1,file1.py::") err_msg = ("Error while validating frames: 'file.py:12:func1,file1.py::'. " "If you want to pass multiple frames, pass a list/tuple of frames " "like ['file.py:12:func1', 'file1.py::'] rather than a comma " "separated string of frames.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_frames_4(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=["file.py:12:func1", "file1.py::,file2.py:34:func2"]) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=["file.py:12:func1", "file1.py::,file2.py:34:func2"]) err_msg = ( "Invalid frame: 'file1.py::,file2.py:34:func2' in frames: ['file.py:12:func1', " "'file1.py::,file2.py:34:func2'] as it contains character ','. If you are " "trying to pass multiple frames, pass them as separate items in the list.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_frames_5(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames="file.py:12:func1..file2.py::..") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="file.py:12:func1..file2.py::..") err_msg = ("Error while validating frames: 'file.py:12:func1..file2.py::..'. " "If you want to pass a range of frames, the correct syntax is " "'first_frame..last_frame'") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_frames_6(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames="file.py:foo:func1") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames="file.py:foo:func1") err_msg = ("Error while validating frame: 'file.py:foo:func1'. The line " "number 'foo' can't be converted to an integer.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_variables_and_exclude_variables(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(variables="foo", exclude_variables="bar") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(variables="foo", exclude_variables="bar") err_msg = "Cannot pass both `variables` and `exclude_variables` parameters." assert str(err.value) == err_msg def test_saveframe_invalid_variables_1(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - variables="var1,var2") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + variables="var1,var2") err_msg = ("Error while validating variables: 'var1,var2'. If you want to pass " "multiple variable names, pass a list/tuple of names like ['var1', " "'var2'] rather than a comma separated string of names.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_variables_2(tmpdir, caplog): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - variables=["var1", "1var2"]) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + variables=["var1", "1var2"]) log_messages = [record.message for record in caplog.records] warning_msg = ("Invalid variable names: ['1var2']. Skipping these variables " "and continuing.") assert warning_msg in log_messages - delattr(sys, "last_value") def test_saveframe_invalid_variables_3(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(TypeError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - variables=1) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(TypeError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + variables=1) err_msg = ("Variables '1' must be of type list, tuple or string (for a single " "variable), not ''") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_exclude_variables_1(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(ValueError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - exclude_variables="var1,var2") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(ValueError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + exclude_variables="var1,var2") err_msg = ("Error while validating variables: 'var1,var2'. If you want to pass " "multiple variable names, pass a list/tuple of names like ['var1', " "'var2'] rather than a comma separated string of names.") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_invalid_exclude_variables_2(tmpdir, caplog): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - exclude_variables=["var1", "1var2"]) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + exclude_variables=["var1", "1var2"]) log_messages = [record.message for record in caplog.records] warning_msg = ("Invalid variable names: ['1var2']. Skipping these variables " "and continuing.") assert warning_msg in log_messages - delattr(sys, "last_value") def test_saveframe_invalid_exclude_variables_3(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - with pytest.raises(TypeError) as err: - saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - exclude_variables=1) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with pytest.raises(TypeError) as err: + saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + exclude_variables=1) err_msg = ("Variables '1' must be of type list, tuple or string (for a single " "variable), not ''") assert str(err.value) == err_msg - delattr(sys, "last_value") def test_saveframe_no_error_raised(tmpdir): if hasattr(sys, "last_value"): delattr(sys, "last_value") + if hasattr(sys, "last_exc"): + delattr(sys, "last_exc") pkg_name = create_pkg(tmpdir) with pytest.raises(RuntimeError) as err: exec(f"from {pkg_name} import init_func2; init_func2()") @@ -567,118 +540,101 @@ def test_saveframe_no_error_raised(tmpdir): def test_saveframe_frame_format_1(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = str(tmpdir / f"saveframe_{get_random()}.pkl") - # Format: 'filename:line_no:func_name' - saveframe(filename=filename, frames="pkg1/mod2.py:10:func2") - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {2} - assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") - assert data[2]["function_name"] == "func2" - - # Format: 'filename::' - saveframe(filename=filename, frames="mod1.py::") - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {3, 4} - assert data[3]["filename"] == str(tmpdir / pkg_name / "mod1.py") - assert data[3]["function_name"] == "func2" - - # Format: 'filename::func_name' - saveframe(filename=filename, frames=f"{pkg_name}/mod1.py::func1") - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {4} - assert data[4]["filename"] == str(tmpdir / pkg_name / "mod1.py") - assert data[4]["function_name"] == "func1" - delattr(sys, "last_value") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + # Format: 'filename:line_no:func_name' + saveframe(filename=filename, frames="pkg1/mod2.py:10:func2") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {2} + assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") + assert data[2]["function_name"] == "func2" + + # Format: 'filename::' + saveframe(filename=filename, frames="mod1.py::") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {3, 4} + assert data[3]["filename"] == str(tmpdir / pkg_name / "mod1.py") + assert data[3]["function_name"] == "func2" + + # Format: 'filename::func_name' + saveframe(filename=filename, frames=f"{pkg_name}/mod1.py::func1") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {4} + assert data[4]["filename"] == str(tmpdir / pkg_name / "mod1.py") + assert data[4]["function_name"] == "func1" def test_saveframe_frame_format_2(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") - saveframe(filename=filename, frames=["__init__.py::", "pkg1/mod2.py:10:func2"]) - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {2, 5} - assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") - assert ( - data[2]["function_qualname"] == - "func2" if VERSION_INFO < (3, 11) else "mod2_cls.func2") + saveframe(filename=filename, frames=["__init__.py::", "pkg1/mod2.py:10:func2"]) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {2, 5} + assert data[2]["filename"] == str(tmpdir / pkg_name / "pkg1" / "mod2.py") + assert ( + data[2]["function_qualname"] == + "func2" if VERSION_INFO < (3, 11) else "mod2_cls.func2") - assert data[5]["filename"] == str(tmpdir / pkg_name / "__init__.py") - assert data[5]["function_qualname"] == "init_func1" + assert data[5]["filename"] == str(tmpdir / pkg_name / "__init__.py") + assert data[5]["function_qualname"] == "init_func1" - saveframe(filename=filename, frames=["pkg1/pkg2/mod3.py:6:", "mod1::"]) - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {1, 3, 4} - assert data[1]["filename"] == str(tmpdir / pkg_name / "pkg1" / "pkg2" / "mod3.py") - assert data[1]["function_qualname"] == "func3" - delattr(sys, "last_value") + saveframe(filename=filename, frames=["pkg1/pkg2/mod3.py:6:", "mod1::"]) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 3, 4} + assert data[1]["filename"] == str(tmpdir / pkg_name / "pkg1" / "pkg2" / "mod3.py") + assert data[1]["function_qualname"] == "func3" def test_saveframe_frame_format_3(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = str(tmpdir / f"saveframe_{get_random()}.pkl") - saveframe(filename=filename, frames=3) - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {1, 2, 3} + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + saveframe(filename=filename, frames=3) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3} - saveframe(filename=filename, frames=5) - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} - delattr(sys, "last_value") + saveframe(filename=filename, frames=5) + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} def test_saveframe_frame_format_4(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = str(tmpdir / f"saveframe_{get_random()}.pkl") - saveframe(filename=filename, frames="pkg1/mod2.py::..__init__.py:6:init_func1") - data = load_pkl(filename) - assert set(data.keys()) == exception_info_keys | {2, 3, 4, 5} + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + saveframe(filename=filename, frames="pkg1/mod2.py::..__init__.py:6:init_func1") + data = load_pkl(filename) + assert set(data.keys()) == exception_info_keys | {2, 3, 4, 5} - with pytest.raises(ValueError) as err: - saveframe(filename=filename, - frames="pkg1/mod3.py::..__init__.py:6:init_func1") - err_msg = "No frame in the traceback matched the frame: 'pkg1/mod3.py::'" - assert str(err.value) == err_msg - delattr(sys, "last_value") + with pytest.raises(ValueError) as err: + saveframe(filename=filename, + frames="pkg1/mod3.py::..__init__.py:6:init_func1") + err_msg = "No frame in the traceback matched the frame: 'pkg1/mod3.py::'" + assert str(err.value) == err_msg def test_saveframe_frame_format_5(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=f"{str(tmpdir)}/{pkg_name}/mod1.py::..") + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=f"{str(tmpdir)}/{pkg_name}/mod1.py::..") data = load_pkl(filename) assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4} - delattr(sys, "last_value") def test_saveframe_variables(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=5, variables=['var1', 'var2']) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=5, variables=['var1', 'var2']) data = load_pkl(filename) assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} @@ -687,17 +643,14 @@ def test_saveframe_variables(tmpdir): assert set(data[3]["variables"].keys()) == {"var1", "var2"} assert set(data[4]["variables"].keys()) == {"var1"} assert set(data[5]["variables"].keys()) == {"var1", "var2"} - delattr(sys, "last_value") def test_saveframe_exclude_variables(tmpdir, caplog): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=5, exclude_variables=['var1', 'var2']) + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = saveframe(filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=5, exclude_variables=['var1', 'var2']) log_messages = [record.message for record in caplog.records] data = load_pkl(filename) assert set(data.keys()) == exception_info_keys | {1, 2, 3, 4, 5} @@ -712,18 +665,15 @@ def test_saveframe_exclude_variables(tmpdir, caplog): f"Cannot pickle variable: 'var3' for frame: 'File: {str(tmpdir)}/{pkg_name}" f"/pkg1/mod2.py, Line: 10, Function: {qualname}'.") assert warning_msg in "\n".join(log_messages) - delattr(sys, "last_value") def test_saveframe_defaults(tmpdir, caplog): pkg_name = create_pkg(tmpdir) - with chdir(tmpdir): - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe() - log_messages = [record.message for record in caplog.records] + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + with chdir(tmpdir): + filename = saveframe() + log_messages = [record.message for record in caplog.records] # Test that saveframe.pkl file in the current working directory is used by # default. info_message = (f"Filename is not passed explicitly using the `filename` " @@ -738,57 +688,42 @@ def test_saveframe_defaults(tmpdir, caplog): # Test that only first frame from the bottom (index = 1) is stored in the # data by default. assert set(data.keys()) == exception_info_keys | {1} - delattr(sys, "last_value") def test_saveframe_frame_metadata(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) - + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) frames_metadata_checker(tmpdir, pkg_name, filename) - delattr(sys, "last_value") def test_saveframe_local_variables_data(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) - + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=5) frames_local_variables_checker(pkg_name, filename) - delattr(sys, "last_value") def test_saveframe_exception_info(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func1; init_func1()") - except ValueError as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=0) - + code = f"from {pkg_name} import init_func1; init_func1()" + with run_code_and_set_exception(code, ValueError): + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), frames=0) exception_info_checker(filename) - delattr(sys, "last_value") def test_saveframe_chained_exceptions(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func3; init_func3()") - except TypeError as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=['__init__.py::init_func3', f'.*/{pkg_name}/.*::']) + code = f"from {pkg_name} import init_func3; init_func3()" + with run_code_and_set_exception(code, TypeError): + filename = saveframe( + filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), + frames=['__init__.py::init_func3', f'.*/{pkg_name}/.*::']) data = load_pkl(filename) assert 1 in set(data.keys()) @@ -803,46 +738,69 @@ def test_saveframe_chained_exceptions(tmpdir): assert data[1]["frame_identifier"] == ( f"{data[1]['filename']},{data[1]['lineno']},{data[1]['function_name']}") assert len(set(data.keys()) - exception_info_keys) == 5 - delattr(sys, "last_value") def test_keyboard_interrupt_frame_metadata(tmpdir): pkg_name = create_pkg(tmpdir) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + code = dedent(f""" + import sys + from pyflyby import saveframe + sys.path.append('{tmpdir}') + from {pkg_name} import init_func4 try: - exec(f"from {pkg_name} import init_func4; init_func4()") + init_func4() except KeyboardInterrupt as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=2) + if {VERSION_INFO[:2]} < (3, 12): + sys.last_value = err + else: + sys.last_exc = err + saveframe(filename='{filename}', frames=2) + """) + run_command(["python", "-c", code]) frames_metadata_checker_for_keyboard_interrupt(tmpdir, pkg_name, filename) - delattr(sys, "last_value") def test_keyboard_interrupt_local_variables_data(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func4; init_func4()") - except KeyboardInterrupt as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=2) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + code = dedent(f""" + import sys + from pyflyby import saveframe + sys.path.append('{tmpdir}') + from {pkg_name} import init_func4 + try: + init_func4() + except KeyboardInterrupt as err: + if {VERSION_INFO[:2]} < (3, 12): + sys.last_value = err + else: + sys.last_exc = err + saveframe(filename='{filename}', frames=2) + """) + run_command(["python", "-c", code]) frames_local_variables_checker_for_keyboard_interrupt(filename) - delattr(sys, "last_value") def test_keyboard_interrupt_exception_info(tmpdir): pkg_name = create_pkg(tmpdir) - try: - exec(f"from {pkg_name} import init_func4; init_func4()") - except KeyboardInterrupt as err: - sys.last_value = err - filename = saveframe( - filename=str(tmpdir / f"saveframe_{get_random()}.pkl"), - frames=0) + filename = str(tmpdir / f"saveframe_{get_random()}.pkl") + code = dedent(f""" + import sys + from pyflyby import saveframe + sys.path.append('{tmpdir}') + from {pkg_name} import init_func4 + try: + init_func4() + except KeyboardInterrupt as err: + if {VERSION_INFO[:2]} < (3, 12): + sys.last_value = err + else: + sys.last_exc = err + saveframe(filename='{filename}', frames=0) + """) + run_command(["python", "-c", code]) exception_info_checker_for_keyboard_interrupt(filename) - delattr(sys, "last_value") def test_saveframe_cmdline_no_exception():