Path: blob/main/scripts/content_checks/nb_autorun.py
3855 views
import re1import sys2import time3import nbformat4import nbconvert5from datetime import datetime6from tools import parse_args, style, indent789class ExecutePreprocessor(nbconvert.preprocessors.ExecutePreprocessor):10"""Need custom preprocessor to skip `uses-hardware` cells"""11def preprocess_cell(self, cell, resources, cell_index):12if hasattr(cell.metadata, 'tags'):13if 'uses-hardware' in cell.metadata.tags:14# Skip execution15return cell, resources16return super().preprocess_cell(cell, resources, cell_index)171819def timestr():20"""Get current time (for reporting in terminal)"""21timestr = f"[{datetime.now().time().strftime('%H:%M')}]"22return style('faint', timestr)232425def contains_code_cells(notebook):26for cell in notebook.cells:27if cell.cell_type == 'code':28return True29return False303132def format_message_terminal(msg):33"""Formats error messages nicely for the terminal"""34outstr = style(msg['severity'], msg['name'])35outstr += f": {msg['description']}"36if 'code' in msg:37outstr += "\nError occurred as result of the following code:\n"38outstr += indent(msg['code'])39return outstr404142def get_warnings(cell):43"""Returns any warning messages from a cell's output"""44warning_messages = []45for output in cell.outputs:46if hasattr(output, 'name') and output.name == 'stderr':47try: # Try to identify warning type48warning_name = re.search(r'(?<=\s)([A-Z][a-z0-9]+)+(?=:)',49output.text)[0]50description = re.split(warning_name,51output.text,52maxsplit=1)[1].strip(' :')53except TypeError:54warning_name = 'Warning'55description = output.text5657warning_messages.append({'name': warning_name,58'severity': 'warning',59'description': description,60'full_output': output.text})61return warning_messages62636465def run_notebook(filepath, write=False, fail_on_warning=False):66"""Attempts to run a notebook and return any error / warning messages.67Args:68filepath (Path): Path to the notebook69write (bool): Whether to write the updated outputs to the file.70Returns:71bool: True if notebook executed without error, False otherwise.72(Note: will not write if there are any errors during execution.)73list: List of dicts containing error / warning message information.74"""75execution_success = True76messages = [] # To collect error / warning messages7778with open(filepath) as f:79notebook = nbformat.read(f, as_version=4)8081if not contains_code_cells(notebook):82# Avoid creating new kernel for no reason83return True, messages8485# Clear outputs86processor = nbconvert.preprocessors.ClearOutputPreprocessor()87processor.preprocess(notebook,88{'metadata': {'path': filepath.parents[0]}})8990# Execute notebook91processor = ExecutePreprocessor(timeout=None)92try:93processor.preprocess(notebook,94{'metadata': {'path': filepath.parents[0]}})95except Exception as err:96err_msg = {'name': err.ename,97'severity': 'error',98'description': err.evalue,99'code': err.traceback.split('------------------')[1],100'full_output': err.traceback101}102messages.append(err_msg)103execution_success = False104105# Search output for warning messages (can't work out how to get the kernel106# to report these)107for cell in notebook.cells:108if cell.cell_type != 'code':109continue110111ignore_warning_tag = (hasattr(cell.metadata, 'tags')112and 'ignore-warning' in cell.metadata.tags)113114warning_messages = get_warnings(cell)115if not ignore_warning_tag:116messages += warning_messages117if fail_on_warning and (messages!=[]):118execution_success = False119120# Clean up unused tags if warning disappears121if ignore_warning_tag and (warning_messages == []):122cell.metadata.tags.remove('ignore-warning')123124# Remove useless execution metadata125for cell in notebook.cells:126if 'execution' in cell.metadata:127del cell.metadata['execution']128129if execution_success and write:130with open(filepath, 'w', encoding='utf-8') as f:131nbformat.write(notebook, f)132133return execution_success, messages134135136if __name__ == '__main__':137# usage: python nb_autorun.py --write --fail-on-warning notebook1.ipynb path/to/notebook2.ipynb138switches, filepaths = parse_args(sys.argv)139140write, fail_on_warning = False, False141for switch in switches:142if switch == '--write':143write = True144if switch == '--fail-on-warning':145fail_on_warning = True146147log = {'t0': time.time(),148'total_time': 0,149'total_files': 0,150'broken_files': 0151}152153# Start executing notebooks154print('\n\033[?25l', end="") # hide cursor155for path in filepaths:156log['total_files'] += 1157print('-', timestr(), path, end=' ', flush=True)158159success, messages = run_notebook(path, write, fail_on_warning)160if success:161print("\r" + style('success', '✔'))162else:163log['broken_files'] += 1164print("\r" + style('error', '✖'))165166if messages:167message_strings = [format_message_terminal(m) for m in messages]168print(indent('\n'.join(message_strings)))169170print('\033[?25h', end='') # un-hide cursor171172# Display output and exit173log['total_time'] = time.time()-log['t0']174print(f"Finished in {log['total_time']:.2f} seconds\n")175if log['broken_files'] > 0:176print(f"Found problems in {log['broken_files']}/{log['total_files']} "177"notebooks, see output above for more info.\n")178if fail_on_warning:179print("If this test failed due to a new warning that is out of "180"scope of\nthis PR, please make a new issue describing the "181"warning, and add\nan `ignore-warning` tag to any problem "182"cells so your PR can pass\nthis test.\n")183sys.exit(1)184sys.exit(0)185186187