Files
learn/frontend/tests/execute_rst_code_blocks.py
2023-04-11 17:31:52 +01:00

291 lines
11 KiB
Python
Executable File

"""
Find all code blocks in a given rst file, and execute all of their defined modes using the defined AWS Lambda function.
"""
import argparse
import json
import os
import sys
import traceback
from pathlib import Path
from typing import Dict, List, Set, Optional
from websocket import WebSocket
SPHINX_PATH = (Path(__file__).parent.parent / 'sphinx').resolve()
sys.path.append(str(SPHINX_PATH))
from widget.chop import manual_chop, real_gnatchop
from code_block import CodeBlock, get_blocks
FUNCTION_URLS = {'prod': 'wss://backend.learn.adacore.com',
'sandbox': 'wss://sandbox.backend.learn.adacore.com'}
EXPECT_FAILURE_CLASSES = {
'ada': ['ada-expect-compile-error', 'ada-run-expect-failure', 'ada-expect-prove-error'],
'c': ['c-expect-compile-error', 'c-run-expect-failure']}
EXPECT_FAILURE_CLASS_BTNS = {
'ada-expect-compile-error': ['compile', 'run'],
'ada-run-expect-failure': ['run'],
'ada-expect-prove-error': ['prove', 'prove_flow', 'prove_flow_report_all', 'prove_report_all'],
'c-expect-compile-error': ['compile', 'run'],
'c-run-expect-failure': ['run']
}
class CodeBlockException(Exception):
pass
def parse_args() -> argparse.Namespace:
"""Parse program arguments
Returns:
argparse.Namespace: parsed arguments object
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('rst_files', type=str, nargs="+", help="The rst file from which to extract doc")
parser.add_argument('--verbose', '-v', action='store_true', help='Show more information')
parser.add_argument('--env', type=str, default='prod', choices=['sandbox', 'prod'],
help='Whether to use the prod or sandbox environment to run the code blocks')
parser.add_argument('--projects', type=str, default='',
help='Comma seperated string of project names to run e.g. Proj_1,Proj_2,Other_proj')
parser.add_argument('--halt-on-failure', action='store_true', help='Exit with non-zero exit status on test failure')
args = parser.parse_args()
args.rst_files = [os.path.abspath(f) for f in args.rst_files]
args.projects = set() if len(args.projects) == 0 else set(args.projects.split(','))
return args
def get_code_blocks(rst_file: str) -> List[CodeBlock]:
"""Reads the provided rst_file and extracts all CodeBlocks from it. Any
CodeBlocks that have a language other than Ada or c are filtered out.
The ordering of CodeBlocks is maintained relative to how they appear in the file.
Args:
rst_file (str): path to rst file to parsed
Raises:
CodeBlockException: if the project name of a CodeBlock is not set
Returns:
List[CodeBlock]: a List of CodeBlocks found in the rst file
"""
with open(rst_file) as f:
content = f.read()
all_blocks = get_blocks(content, rst_file)
filtered_blocks = []
for b in all_blocks:
if not isinstance(b, CodeBlock): continue
if not b.language in ['ada', 'c']: continue
if b.project is None:
raise CodeBlockException(f"Error: project not set: {b.loc}")
filtered_blocks.append(b)
return filtered_blocks
def group_by_project(blocks: List[CodeBlock]) -> Dict[str, List[CodeBlock]]:
"""Group a list of CodeBlocks by their project name. The ordering of CodeBlocks is maintained
Args:
blocks (List[CodeBlock]): CodeBlocks ot be grouped
Returns:
Dict[str, List[CodeBlock]]: map of projects to a list of CodeBlocks that share that project name
"""
projects: Dict[str, List[CodeBlock]] = {}
for b in code_blocks:
if b.project not in projects:
projects[b.project] = []
projects[b.project].append(b)
return projects
def filter_projects(projects: Dict[str, List[CodeBlock]],
keep: Set[str]) -> Dict[str, List[CodeBlock]]:
"""Filter a dictionary of projects, only keeping projects whose name is in `keep`.
If `keep` is emtpy, it will return all projects.
Args:
projects (Dict[str, List[CodeBlock]]): dict of projects to filter
keep (Set[str]): Optional set of project names to keep.
Returns:
Dict[str, List[CodeBlock]]: filtered dict of projects
"""
if len(keep) == 0:
return projects
return {k: v for k, v in projects.items() if k in keep}
def call_websocket_endpoint(name: str,
mode: str,
main_file: str,
switches: Dict[str, List[str]],
files: List[Dict[str, str]],
websocket_url: str) -> Optional[List[dict]]:
"""Call the websocket endpoint with the provided information
Args:
name (str): used to identify a run
mode (str): mode to be executed. Relates the button being pressed
main_file (str): file name of the main file. Empty if one is not defined
switches (Dict[str, List[str]]): map of switch type, to a list of switches to be set
files (List[Dict[str, str]]): List of dicts that contain filenames, and file contents
websocket_url (str): websocket url to be used
Returns:
Optional[List[dict]]: list of responses if successful, None otherwise
"""
msg = msg = {
"action": "execute",
"data": {
"files": files,
"main": main_file,
"mode": mode,
"switches": switches,
"name": name,
"lab": False
}
}
retry_count = 5
all_exceptions = []
while retry_count > 0:
retry_count -= 1
try:
ws = WebSocket()
ws.connect(websocket_url)
ws.send(json.dumps(msg))
received = None
result = []
result_with_msg = [msg]
while True:
received = json.loads(ws.recv())
result.append(received)
if 'connectionId' in received:
break
if received['completed']:
break
ws.close()
result_with_msg.extend(result)
return result_with_msg
except Exception as e:
print(f"Retry: {name}")
all_exceptions.append[e]
print(f"Failed to run: {name}: {all_exceptions}")
return None
def run_code_block(block: CodeBlock,
deps: Dict[str, str],
websocket_url: str,
verbose: bool,
halt_on_failure: bool):
"""Run a single code block using the provided url. `deps` should contain a mapping of file names to file
contents from the same project, but earlier in the file. This is because projects files are accumulated if not
overwritten. `deps` will also be updated with all files in the current CodeBlock.
Args:
block (CodeBlock): to be run
deps (Dict[str, str]): dict of file names to file contents files from earlier CodeBlocks in the same project
websocket_url (str): url of the websocket endpoint that should be used to execute a task
verbose (bool): if True, extra log statements will be emitted
halt_on_failure (bool): if True and a test fails, exit with a non-zero exit status
"""#
# Extract source files from the current editor block
if block.manual_chop:
source_files = manual_chop(block.text.splitlines())
else:
source_files = real_gnatchop(block.text.splitlines())
# Replace any existing project files with the same name
for f in source_files:
deps[f.basename] = f.content
# Update the list of source files with the previously found files
source_files = [{'basename': f, 'contents': deps[f]} for f in deps]
# Skip the CodeBlock if ...
no_check = any(sphinx_class in ["ada-nocheck", "c-nocheck"] for sphinx_class in block.classes)
if no_check or not block.run or 'ada-syntax-only' in block.classes or 'ada-norun' in block.classes:
if verbose:
print(f"Skipping code block: {block.loc}")
return
# If there are no source files something went wrong
if len(source_files) == 0:
print(f"Skipping code block: Failed to chop example: {block.loc}")
return
main_file = "" if block.main_file is None else block.main_file
switches = {}
if len(block.compiler_switches) > 0:
switches['Compiler'] = block.compiler_switches
# For each button, run the CodeBlock using the lambda function
for b in block.buttons:
if b == 'no':
continue
name = f"{block.project}.{block.loc}.{b}"
res = call_websocket_endpoint(name=name,
mode=b,
main_file=main_file,
switches=switches,
files=source_files,
websocket_url=websocket_url)
if res is None:
continue
if 'connectionId' in res[-1]:
print(f"AWS error: {name}: {res}")
continue
# If it ran, check that the exit status matched the expectation
exit_status = res[-1]['status']
expect_failure = False
for c in EXPECT_FAILURE_CLASSES[block.language]:
if c in block.classes and b in EXPECT_FAILURE_CLASS_BTNS[c]:
expect_failure = True
break
if expect_failure and exit_status == 0:
print(f"Fail: {name}: expected failure, received zero exit status: {res}")
if halt_on_failure:
exit(1)
elif not expect_failure and exit_status != 0:
print(f"Fail: {name}: expected success, received non-zero exit status: {res}")
if halt_on_failure:
exit(2)
elif verbose:
print(f"Pass: {name}")
def run_all_projects(projects: Dict[str, List[CodeBlock]],
websocket_url: str,
verbose: bool,
halt_on_failure: bool):
"""Run all of the CodeBlocks in `projects` using the websocket url
Args:
projects (Dict[str, List[CodeBlock]]): dict of project names that each map to a list of CodeBlocks to be run
websocket_url (str): url of the websocket endpoint that should be used to execute a task
verbose (bool): if True, extra log statements will be emitted
halt_on_failure (bool): if True and a test fails, exit with a non-zero exit status
"""
all_deps = {}
for p in projects:
for b in projects[p]:
if p not in all_deps:
all_deps[p] = {}
run_code_block(b, all_deps[p], websocket_url, verbose, halt_on_failure)
if __name__ == "__main__":
args = parse_args()
lambda_url = FUNCTION_URLS[args.env]
for f in args.rst_files:
code_blocks = get_code_blocks(f)
projects = group_by_project(code_blocks)
projects = filter_projects(projects, args.projects)
run_all_projects(projects, lambda_url, args.verbose, args.halt_on_failure)