From 5f5038191ee7a2d4b7f66c11938075404846c5e6 Mon Sep 17 00:00:00 2001 From: Markus Stahl Date: Thu, 9 Nov 2023 10:27:23 +0100 Subject: [PATCH 1/5] make sync call non-blocking --- RobotFrameworkService/main.py | 75 +++++--- .../routers/robotframework.py | 166 ++++++++++++------ 2 files changed, 163 insertions(+), 78 deletions(-) diff --git a/RobotFrameworkService/main.py b/RobotFrameworkService/main.py index 19ce7bc..b1901d2 100644 --- a/RobotFrameworkService/main.py +++ b/RobotFrameworkService/main.py @@ -1,3 +1,5 @@ +from concurrent.futures import ProcessPoolExecutor +from contextlib import asynccontextmanager import os import pathlib import sys @@ -14,8 +16,15 @@ from .constants import APP_NAME, LOGS +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.executor = ProcessPoolExecutor() + yield + app.state.executor.shutdown() + + pathlib.Path(LOGS).mkdir(exist_ok=True) -app = FastAPI(title=APP_NAME, version=get_version()) +app = FastAPI(title=APP_NAME, version=get_version(), lifespan=lifespan) app.include_router(robotframework.router) app.mount(f"/{LOGS}", StaticFiles(directory=LOGS), name="robotlog") @@ -23,12 +32,9 @@ @app.middleware("http") async def request_middleware(request: Request, call_next): request_id = str(uuid.uuid4()) - + request.headers.__dict__["_list"].append( - ( - "request-id".encode(), - request_id.encode() - ) + ("request-id".encode(), request_id.encode()) ) try: response = await call_next(request) @@ -42,17 +48,19 @@ async def request_middleware(request: Request, call_next): return response -@app.get('/') +@app.get("/") async def greetings(request: Request): - return 'web service for starting robot tasks' + return "web service for starting robot tasks" -@app.get('/status/') +@app.get("/status/") async def server_status(): - status = {'python version': sys.version, - 'platform': sys.platform, - 'arguments': sys.argv, - 'application': APP_NAME} + status = { + "python version": sys.version, + "platform": sys.platform, + "arguments": sys.argv, + "application": APP_NAME, + } return status @@ -64,17 +72,42 @@ def get_config(): import argparse parser = argparse.ArgumentParser() - parser.add_argument("-t", "--taskfolder", default='tasks', help="Folder with tasks service will executed") - parser.add_argument('--version', action='version', version=f'Robot Framework Webservice {get_version()}') - parser.add_argument("-p", "--port", default=os.environ.get('RFS_PORT', default=5003), type=int, help="Port of Robot Framework Webservice") - parser.add_argument("-V", "--variablefiles", nargs='*', default=None, help="List of files containing variables") + parser.add_argument( + "-t", + "--taskfolder", + default="tasks", + help="Folder with tasks service will executed", + ) + parser.add_argument( + "--version", + action="version", + version=f"Robot Framework Webservice {get_version()}", + ) + parser.add_argument( + "-p", + "--port", + default=os.environ.get("RFS_PORT", default=5003), + type=int, + help="Port of Robot Framework Webservice", + ) + parser.add_argument( + "-V", + "--variablefiles", + nargs="*", + default=None, + help="List of files containing variables", + ) parser.add_argument("-b", "--debugfile", default=None, help="Debug output file") - parser.add_argument("--removekeywords", default="tag:secret", help="Remove keyword details from reports") + parser.add_argument( + "--removekeywords", + default="tag:secret", + help="Remove keyword details from reports", + ) args = parser.parse_args() RFS_Config().cmd_args = args - server = Server(config=(Config(app=app, loop="asyncio", host="0.0.0.0", port=args.port))) + server = Server( + config=(Config(app=app, loop="asyncio", host="0.0.0.0", port=args.port)) + ) server.run() - - diff --git a/RobotFrameworkService/routers/robotframework.py b/RobotFrameworkService/routers/robotframework.py index d1e78a1..addd0fc 100644 --- a/RobotFrameworkService/routers/robotframework.py +++ b/RobotFrameworkService/routers/robotframework.py @@ -1,3 +1,4 @@ +from concurrent.futures import Executor import pathlib from fastapi import APIRouter, Request, Path @@ -11,46 +12,62 @@ import multiprocessing as mp +from concurrent.futures import Future +import threading + +import asyncio + router = APIRouter( prefix="/robotframework", - responses={404: {"description": "Not found: Webservice is either busy or requested endpoint is not supported."}}, + responses={ + 404: { + "description": "Not found: Webservice is either busy or requested endpoint is not supported." + } + }, ) + async def run_robot_in_brackground(func, args=[], kwargs={}): p = mp.Process(target=func, args=args, kwargs=kwargs) p.start() return p -async def run_robot_and_wait(func, args=[], kwargs={}): - # this is still blocking - result: int = func(*args, **kwargs) + +async def run_robot_and_wait(executor: Executor, func, args=[], kwargs={}): + # run robot concurrently and wait for it. + loop = asyncio.get_event_loop() + result: int = await loop.run_in_executor(executor, func, *args) if result == 0: - result_page = 'PASS' + result_page = "PASS" result_page += f'

Go to log

' status_code = 200 elif 250 >= result >= 1: - result_page = f'FAIL: {result} tasks failed' + result_page = f"FAIL: {result} tasks failed" result_page += f'

Go to log

' status_code = 400 else: - result_page = f'FAIL: Errorcode {result}' + result_page = f"FAIL: Errorcode {result}" status_code = 500 - - return Response(content=result_page, media_type="text/html", status_code=status_code) + + return Response( + content=result_page, media_type="text/html", status_code=status_code + ) -@router.get('/run/all', tags=["execution"]) +@router.get("/run/all", tags=["execution"]) async def run_all(request: Request): """ Run all task available. """ id = request.headers["request-id"] - response = await run_robot_and_wait(func=_start_all_robot_tasks, args=[id]) - + response = await run_robot_and_wait( + request.app.state.executor, func=_start_all_robot_tasks, args=[id] + ) + return response -@router.get('/run/all/async', tags=["execution"]) +@router.get("/run/all/async", tags=["execution"]) async def run_all_async(request: Request): """ Starts all Robot tasks. Returns execution id and continures to run Robot tasks in background. @@ -60,116 +77,149 @@ async def run_all_async(request: Request): return id -@router.get('/run/{task}', tags=["execution"]) +@router.get("/run/{task}", tags=["execution"]) async def run_task(task, request: Request): """ Run a given task. """ id = request.headers["request-id"] - variables = RequestHelper.parse_variables_from_query(request) - response = await run_robot_and_wait(func=_start_specific_robot_task, kwargs={'id': id, 'task':task, 'variables':variables}) + variables = RequestHelper.parse_variables_from_query(request) + response = await run_robot_and_wait( + request.app.state.executor, + func=_start_specific_robot_task, + args=[id, task, variables], + ) return response -@router.get('/run/{task}/async', tags=["execution"]) +@router.get("/run/{task}/async", tags=["execution"]) async def run_task_async(task, request: Request): """ Start a given task. Returns execution id and continues to run Robot task in background. """ id = request.headers["request-id"] - variables = RequestHelper.parse_variables_from_query(request) - await run_robot_in_brackground(func=_start_specific_robot_task, kwargs={'task':task, 'variables':variables}) + variables = RequestHelper.parse_variables_from_query(request) + await run_robot_in_brackground( + func=_start_specific_robot_task, + kwargs={"id": id, "task": task, "variables": variables}, + ) return id -@router.get('/run/suite/{suite}', tags=["execution"]) + +@router.get("/run/suite/{suite}", tags=["execution"]) async def run_suite(suite, request: Request): """ Run a given suite. """ id = request.headers["request-id"] variables = RequestHelper.parse_variables_from_query(request) - response = await run_robot_and_wait(func=_start_specific_robot_suite, kwargs={'id': id, 'suite':suite, 'variables':variables}) + response = await run_robot_and_wait( + request.app.state.executor, + func=_start_specific_robot_suite, + args=[id, suite, variables], + ) return response -@router.get('/run/suite/{suite}/async', tags=["execution"]) + +@router.get("/run/suite/{suite}/async", tags=["execution"]) async def run_suite_async(suite, request: Request): """ Start a given suite. Returns execution id and continues to run Robot suite in background. """ id = request.headers["request-id"] variables = RequestHelper.parse_variables_from_query(request) - await run_robot_in_brackground(func=_start_specific_robot_suite, kwargs={'suite':suite, 'variables':variables}) + await run_robot_in_brackground( + func=_start_specific_robot_suite, + kwargs={"id": id, "suite": suite, "variables": variables}, + ) return id -@router.get('/run_and_show/{task}', tags=["execution"], response_class=HTMLResponse) +@router.get("/run_and_show/{task}", tags=["execution"], response_class=HTMLResponse) async def start_robot_task_and_show_log(task: str, request: Request): """ Run a given task with variables and return log.html """ id = request.headers["request-id"] variables = RequestHelper.parse_variables_from_query(request) - await run_robot_and_wait(func=_start_specific_robot_task, kwargs={'id':id, 'task':task, 'variables':variables}) + await run_robot_and_wait( + request.app.state.executor, + func=_start_specific_robot_task, + args=[id, task, variables], + ) return RedirectResponse(f"/logs/{id}/log.html") -@router.get('/run_and_show_report/{task}', tags=["execution"], response_class=HTMLResponse) +@router.get( + "/run_and_show_report/{task}", tags=["execution"], response_class=HTMLResponse +) async def start_robot_task_and_show_report(task: str, request: Request): """ Run a given task with variables and return report.html """ id = request.headers["request-id"] variables = RequestHelper.parse_variables_from_query(request) - await run_robot_and_wait(func=_start_specific_robot_task, kwargs={'id':id, 'task':task, 'variables':variables}) + await run_robot_and_wait( + func=_start_specific_robot_task, + kwargs={"id": id, "task": task, "variables": variables}, + ) return RedirectResponse(f"/logs/{id}/report.html") -@router.get('/show_log/{executionid}', tags=["reporting"], response_class=HTMLResponse) -async def show_log(executionid: str = Path( - title="ID of a previous request", - description="Insert here the value of a previous response header field 'x-request-id'" -) +@router.get("/show_log/{executionid}", tags=["reporting"], response_class=HTMLResponse) +async def show_log( + executionid: str = Path( + title="ID of a previous request", + description="Insert here the value of a previous response header field 'x-request-id'", + ) ): """ Show most recent log.html from a given execution """ - return RedirectResponse(f'/logs/{executionid}/log.html') + return RedirectResponse(f"/logs/{executionid}/log.html") -@router.get('/show_report/{executionid}', tags=["reporting"], response_class=HTMLResponse) -async def show_report(executionid: str = Path( - title="ID of a previous request", - description="Insert here the value of a previous response header field 'x-request-id'" +@router.get( + "/show_report/{executionid}", tags=["reporting"], response_class=HTMLResponse ) +async def show_report( + executionid: str = Path( + title="ID of a previous request", + description="Insert here the value of a previous response header field 'x-request-id'", + ) ): """ Show most recent report.html from a given execution """ - return RedirectResponse(f'/logs/{executionid}/report.html') + return RedirectResponse(f"/logs/{executionid}/report.html") -@router.get('/show_output/{executionid}', tags=["reporting"], response_class=HTMLResponse) -async def show_raw_output(executionid: str = Path( - title="ID of a previous request", - description="Insert here the value of a previous response header field 'x-request-id'" +@router.get( + "/show_output/{executionid}", tags=["reporting"], response_class=HTMLResponse ) +async def show_raw_output( + executionid: str = Path( + title="ID of a previous request", + description="Insert here the value of a previous response header field 'x-request-id'", + ) ): """ Show most recent report.html from a given execution """ - return RedirectResponse(f'/logs/{executionid}/output.xml') + return RedirectResponse(f"/logs/{executionid}/output.xml") -@router.get('/executions', tags=["execution"], response_class=JSONResponse) +@router.get("/executions", tags=["execution"], response_class=JSONResponse) async def show_execution_ids(): """ get all execution ids for the finished tasks """ - logs = pathlib.Path(f'./{LOGS}') - is_execution_finished = (lambda x: - x.is_dir() - and (x / 'report.html').exists() - and (x / 'log.html').exists()) + logs = pathlib.Path(f"./{LOGS}") + is_execution_finished = ( + lambda x: x.is_dir() + and (x / "report.html").exists() + and (x / "log.html").exists() + ) return [log.stem for log in logs.iterdir() if is_execution_finished(log)] @@ -184,14 +234,15 @@ def _start_all_robot_tasks(id: str, variables: list = None) -> int: return robot.run( config.taskfolder, - outputdir=f'logs/{id}', + outputdir=f"logs/{id}", debugfile=config.debugfile, variable=variables, variablefile=variablefiles, - consolewidth=120 + consolewidth=120, ) -def _start_specific_robot_suite(id: str, suite: str, variables: list=None) -> int: + +def _start_specific_robot_suite(id: str, suite: str, variables: list = None) -> int: config = RFS_Config().cmd_args if variables is None: variables = [] @@ -203,13 +254,14 @@ def _start_specific_robot_suite(id: str, suite: str, variables: list=None) -> in return robot.run( config.taskfolder, suite=suite, - outputdir=f'logs/{id}', + outputdir=f"logs/{id}", debugfile=config.debugfile, variable=variables, variablefile=variablefiles, - consolewidth=120 + consolewidth=120, ) + def _start_specific_robot_task(id: str, task: str, variables: list = None) -> int: config = RFS_Config().cmd_args if variables is None: @@ -222,15 +274,15 @@ def _start_specific_robot_task(id: str, task: str, variables: list = None) -> in return robot.run( config.taskfolder, task=task, - outputdir=f'logs/{id}', + outputdir=f"logs/{id}", debugfile=config.debugfile, variable=variables, variablefile=variablefiles, - consolewidth=120 + consolewidth=120, ) class RequestHelper: @staticmethod def parse_variables_from_query(arguments: Request) -> list: - return [f'{k}:{v}' for k, v in arguments.query_params.items()] + return [f"{k}:{v}" for k, v in arguments.query_params.items()] From 4f4226e97d84bca46063e9a10588754e679d4b2f Mon Sep 17 00:00:00 2001 From: Markus Stahl Date: Thu, 9 Nov 2023 10:33:37 +0100 Subject: [PATCH 2/5] documentation --- README.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 25e9c20..78a2a96 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ A web service managing Robot Framework tasks. -**Status: Prototype** - # Goal This web service shall start Robot Framework tasks and return and cache the according reports. @@ -55,13 +53,25 @@ Call robot task with variables: http://localhost:5003/robotframework/run/mytask?myVariable1=42&anotherVariable=Mustermann -Response contains a header field `x-request-id` that can be used to retrieve logs and reports of this execution asynchronously. +Response contains a header field `x-request-id` that can be used to retrieve logs and reports of this execution asynchronously (see reporting endpoints) + +There are endpoints for synchronous and asynchronous request: + +``` +# connection remains open for duration of my task +http://localhost:5003/robotframework/run/mytask + +# connection closes immediately - result must be requested with the x-request-id +http://localhost:5003/robotframework/run/mytask/async +``` + +**There is no limitation on executed Robot processes! It is easy to push the webservice in DOS with too many requests at once** ## Reporting Endpoints that provide `log.html` and `report.html` for a specific task execution. You require the `x-request-id` from a previous response that triggered the execution. -## Start web service +# Start web service The web service starts automatically with uvicorn inside. Simply call: @@ -71,17 +81,17 @@ You can check available options with python -m RobotFrameworkService.main --help -### Example: +## Example: python -m RobotFrameworkService.main -p 5003 -t path_to_my_taskfolder -### Example - Variablefiles: +## Example - Variablefiles: You can provide variable files that are passed to all robot suites on execution: python -m RobotFrameworkService.main -p 5003 -t path_to_my_taskfolder --variablefiles config/env/test.py -## Custom WSGI server +# Custom WSGI server You can start RobotFrameworkService with bare WSGI servers: @@ -91,16 +101,16 @@ Or start web service with other WSGI server, i.e waitress: waitress-serve --port 5003 RotbotFrameworkService.main:app -## SwaggerUi +# SwaggerUi Swagger-UI is available under `http://localhost:5003/docs` -## Demo-Tasks +# Demo-Tasks This project contains some tasks for demonstration. They are located in ``tasks`` folder. You may add your own task suites in that directory, if you like. -## Task name with spaces in URL +# Task name with spaces in URL Tasks may contain spaces, URL must not. Luckily, Robot Framework supports CamelCase as well as snake_case syntax. Meaning: "Another Task" can be trigger in url with parameter `AnotherTask` or ``another_task`` From a3d81cb2abfcfb40e2cc3d98ee4f45a02e70d63b Mon Sep 17 00:00:00 2001 From: Markus Stahl Date: Thu, 9 Nov 2023 10:45:07 +0100 Subject: [PATCH 3/5] some unittests --- tests/test_app.py | 60 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index f3e638c..863a417 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,54 +4,80 @@ class EndpointTesttest_s(unittest.TestCase): - def test_is_service_available(self): response = self.__get_robot_webservice("/status") - + + def test_is_robottask_startable(self): + response = self.__get_robot_webservice( + "/robotframework/run/all", expected_response_code=400 + ) + self.__is_robot_failed(response=response) + + def test_is_robottask_async_startable(self): + self.__get_robot_webservice("/robotframework/run/all/async") + def test_is_robottask_startable(self): response = self.__get_robot_webservice("/robotframework/run/anotherTask") self.__is_robot_passed(response=response) + def test_is_robottask_async_startable(self): + self.__get_robot_webservice("/robotframework/run/anotherTask/async") + def test_robottask_with_variables(self): - response = self.__get_robot_webservice("/robotframework/run/Task with variable?input=qwerty") - self.__is_robot_passed(response=response, msg='Testing with variables failed') + response = self.__get_robot_webservice( + "/robotframework/run/Task with variable?input=qwerty" + ) + self.__is_robot_passed(response=response, msg="Testing with variables failed") def test_is_robottask_available_with_logs(self): - response = self.__get_robot_webservice("/robotframework/run_and_show/anotherTask") - self.assertIn('PASS',response.text) + response = self.__get_robot_webservice( + "/robotframework/run_and_show/anotherTask" + ) + self.assertIn("PASS", response.text) def test_is_robottask_available_with_reports(self): - response = self.__get_robot_webservice("/robotframework/run_and_show_report/anotherTask") - self.assertIn('PASS',response.text) + response = self.__get_robot_webservice( + "/robotframework/run_and_show_report/anotherTask" + ) + self.assertIn("PASS", response.text) def test_is_robottask_available_with_logs_and_arguments(self): - response = self.__get_robot_webservice("/robotframework/run_and_show/anotherTask?art=tests&description=EreichbarkeitsTestMitLogs") - self.assertIn('PASS',response.text) + response = self.__get_robot_webservice( + "/robotframework/run_and_show/anotherTask?art=tests&description=EreichbarkeitsTestMitLogs" + ) + self.assertIn("PASS", response.text) def test_is_robottask_available_with_reports_and_arguments(self): - response = self.__get_robot_webservice("/robotframework/run_and_show_report/anotherTask?art=tests&description=FunktionsTestMitReports") - self.assertIn('PASS',response.text) + response = self.__get_robot_webservice( + "/robotframework/run_and_show_report/anotherTask?art=tests&description=FunktionsTestMitReports" + ) + self.assertIn("PASS", response.text) def test_is_robotlog_available(self): with TestClient(app) as client: run_response = client.get("/robotframework/run/anotherTask") execution_id = run_response.headers["x-request-id"] - logs_response = client.get(f'/robotframework/show_log/{execution_id}') + logs_response = client.get(f"/robotframework/show_log/{execution_id}") self.assertEqual(200, logs_response.status_code) def test_is_robotreport_available(self): with TestClient(app) as client: run_response = client.get("/robotframework/run/anotherTask") execution_id = run_response.headers["x-request-id"] - report_response = client.get(f'/robotframework/show_report/{execution_id}') + report_response = client.get(f"/robotframework/show_report/{execution_id}") self.assertEqual(200, report_response.status_code) - def __get_robot_webservice(self,endpoint, expected_response_code=200): + def __get_robot_webservice(self, endpoint, expected_response_code=200): with TestClient(app) as client: response = client.get(endpoint) self.assertEqual(expected_response_code, response.status_code, response.text) return response def __is_robot_passed(self, response, msg=None): - self.assertNotIn('FAIL', response.text, msg=msg) - self.assertIn('PASS',response.text, 'Test result contains neither PASS nor FAIL') + self.assertNotIn("FAIL", response.text, msg=msg) + self.assertIn( + "PASS", response.text, "Test result contains neither PASS nor FAIL" + ) + + def __is_robot_failed(self, response, msg=None): + self.assertIn("FAIL", response.text, "Test result contains FAIL") From 1ee42c2ff12208c656911a928997abefe39797ca Mon Sep 17 00:00:00 2001 From: Markus Stahl Date: Thu, 9 Nov 2023 10:51:41 +0100 Subject: [PATCH 4/5] fix show with reports --- RobotFrameworkService/routers/robotframework.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RobotFrameworkService/routers/robotframework.py b/RobotFrameworkService/routers/robotframework.py index addd0fc..01ed4de 100644 --- a/RobotFrameworkService/routers/robotframework.py +++ b/RobotFrameworkService/routers/robotframework.py @@ -160,8 +160,9 @@ async def start_robot_task_and_show_report(task: str, request: Request): id = request.headers["request-id"] variables = RequestHelper.parse_variables_from_query(request) await run_robot_and_wait( + request.app.state.executor, func=_start_specific_robot_task, - kwargs={"id": id, "task": task, "variables": variables}, + args=[id, task, variables], ) return RedirectResponse(f"/logs/{id}/report.html") From 419140330031f7c0cfe96dfea00435b9226f8042 Mon Sep 17 00:00:00 2001 From: Markus Stahl Date: Thu, 9 Nov 2023 10:51:47 +0100 Subject: [PATCH 5/5] remove old print --- RobotFrameworkService/Config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/RobotFrameworkService/Config.py b/RobotFrameworkService/Config.py index 4a6c517..8ed5c30 100644 --- a/RobotFrameworkService/Config.py +++ b/RobotFrameworkService/Config.py @@ -1,9 +1,9 @@ class Config: class Default: - taskfolder = 'tasks' + taskfolder = "tasks" variablefiles = None debugfile = None - + """ Service Config as Singleton """ @@ -13,7 +13,6 @@ class Default: def __new__(cls): if cls._instance is None: - print('Creatin the object') cls._instance = super(Config, cls).__new__(cls) cls._instance.cmd_args = Config.Default() return cls._instance @@ -24,4 +23,4 @@ def cmd_args(self) -> str: @cmd_args.setter def cmd_args(self, value: str): - self._cmd_args = value \ No newline at end of file + self._cmd_args = value