diff --git a/.gitignore b/.gitignore index c5b7a256..37a6661d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ output.xml log.html report.html +robot2python_output \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 0be14c79..2ba6ac6f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,15 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "robot2python", + "type": "python", + "request": "launch", + "program": "pytest_robotframework/robot2python.py", + "args": ["${input:robot2pythonInput}", "robot2python_output"], + "console": "integratedTerminal", + "justMyCode": true + }, { "name": "pytest justMyCode disabled", "type": "python", @@ -19,6 +28,12 @@ "type": "promptString", "description": "args for pytest", "default": "" + }, + { + "id": "robot2pythonInput", + "type": "promptString", + "description": "robot test suite to convert to python", + "default": "" } ] } diff --git a/README.md b/README.md index c214f33b..9d035451 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,23 @@ def test_bar(): ... ``` +## automatically convert `.robot` tests to `.py` + +pytest-robotframework comes with a script to automatically convert tests from robot to python: + +``` +robot2python ./foo.robot ./tests +``` + +this will convert the `foo.robot` file to an equivalent `test_foo.py` file and output it to the `tests` directory. + +### limitations + +note that the script is not perfect and you will probably have to make manual changes to tests converted with it. + +- robot converters are not yet used, so all arguments to keywords are assumed to be strings in the converted python code. +- some of the control flows possible in robot aren't able to be accurately converted to python (see [here](#continuable-failures-dont-work)). the script attempts to convert what it can but you will probably have to rewrite parts of your tests that use continuable failures + ## setup/teardown and other hooks to define a function that runs for each test at setup or teardown, create a `conftest.py` with a `pytest_runtest_setup` and/or `pytest_runtest_teardown` function: diff --git a/pdm.lock b/pdm.lock index e1f68cbf..0d439e2b 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "lint", "test", "docs"] strategy = ["cross_platform"] lock_version = "4.4" -content_hash = "sha256:cb3bfc2ccb1a4d37dfcd96508f2e6c467dc2ea87a161fefa377ae64922222a93" +content_hash = "sha256:582b5a5affc621a8cda43b791a7a21fe823af1d8d33c0482a6e3c5045c8974aa" [[package]] name = "astroid" @@ -707,6 +707,20 @@ files = [ {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, ] +[[package]] +name = "typer" +version = "0.9.0" +requires_python = ">=3.6" +summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." +dependencies = [ + "click<9.0.0,>=7.1.1", + "typing-extensions>=3.7.4.3", +] +files = [ + {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"}, + {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, +] + [[package]] name = "typing-extensions" version = "4.8.0" diff --git a/pyproject.toml b/pyproject.toml index 9d8f13ae..84f8578d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,8 @@ dependencies = [ "robotframework<7.0.0,>=6.1.1", "deepmerge<2.0.0,>=1.1.0", "basedtyping<0.2,>=0.1.0", + "typer>=0.9.0", + "astunparse>=1.6.3", ] requires-python = ">=3.8,<4.0" readme = "README.md" @@ -21,6 +23,9 @@ repository = "https://github.com/detachhead/pytest-robotframework" [project.entry-points.pytest11] robotframework = "pytest_robotframework._internal.plugin" +[project.scripts] +robot2python = "pytest_robotframework._internal.scripts.robot2python:main" + [tool.pyprojectx] pdm = "pdm==2.10.4" @@ -221,7 +226,6 @@ enable = [ "unnecessary-ellipsis", "unreachable", "unused-private-member", - "unused-wildcard-import", "useless-param-doc", "useless-parent-delegation", "useless-type-doc", @@ -274,9 +278,9 @@ xfail_strict = true enable_assertion_pass_hook = true [tool.mypy] -allow_redefinition = true +allow_redefinition = false default_return = false -cache_dir = 'nul' # disable cache because it sucks +cache_dir = 'nul' # disable cache because it sucks [[tool.mypy.overrides]] module = ['robot.*'] @@ -284,13 +288,14 @@ no_implicit_reexport = false ignore_missing_py_typed = true # https://github.com/robotframework/robotframework/issues/4822 [[tool.mypy.overrides]] -module = ['deepmerge.*'] +module = ['deepmerge.*', 'astunparse.*'] ignore_missing_py_typed = true [[tool.mypy.overrides]] module = ['pytest_robotframework.*', 'tests.*'] default_return = true + [tool.pyright] pythonVersion = "3.8" pythonPlatform = "All" @@ -380,6 +385,10 @@ fixture-parentheses = false "tests/**/*.py" = [ "S101", # Use of assert detected (pytest uses assert statements) ] +"tests/fixtures/test_robot2python/**/*.py" = [ + "INP001", # implicit-namespace-package +] + [tool.ruff.isort] combine-as-imports = true required-imports = ["from __future__ import annotations"] diff --git a/pytest_robotframework/_internal/scripts/__init__.py b/pytest_robotframework/_internal/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest_robotframework/_internal/scripts/robot2python.py b/pytest_robotframework/_internal/scripts/robot2python.py new file mode 100644 index 00000000..68d92b82 --- /dev/null +++ b/pytest_robotframework/_internal/scripts/robot2python.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from ast import ( + Call, + Constant, + Expr, + FunctionDef, + ImportFrom, + Module, + Name, + alias, + arg, + arguments, + expr, + parse, + stmt, +) +from contextlib import contextmanager, nullcontext +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING, Iterator, cast + +from robot.api import SuiteVisitor, logger +from robot.api.interfaces import ( + ListenerV2, + StartKeywordAttributes, + StartSuiteAttributes, +) +from robot.api.parsing import ModelVisitor +from robot.libraries import STDLIBS +from robot.run import RobotFramework +from typer import run +from typing_extensions import override + +import pytest_robotframework +from pytest_robotframework._internal.errors import InternalError, UserError +from pytest_robotframework._internal.utils import unparse + +if TYPE_CHECKING: + from types import ModuleType + + from robot import model, result + + +def _pythonify_name(name: str) -> str: + return name.lower().replace(" ", "_") + + +def _pytestify_name(name: str) -> str: + return f"test_{_pythonify_name(name)}" + + +def _module_name(module: ModuleType | str) -> str: + return module if isinstance(module, str) else module.__name__ + + +def _robot_file(suite: model.TestSuite) -> Path | None: + if suite.source is None: + raise InternalError(f"ayo whyyo suite aint got no path 💀 ({suite.name})") + suite_path = Path(suite.source) + return suite_path if suite_path.suffix == ".robot" else None + + +class Robot2PythonListener(ListenerV2): + @override + def start_keyword(self, name: str, attributes: StartKeywordAttributes): + super().start_keyword(name, attributes) + + +class Robot2Python(SuiteVisitor): + def __init__(self, output_dir: Path) -> None: + self.modules: dict[Path, Module] = {} + self.output_dir = output_dir + self.current_module: Module + self.statement_stack: list[stmt] = [] + + @property + def context(self) -> stmt: + return self.statement_stack[-1] + + @contextmanager + def _stack_frame(self, statement: stmt) -> Iterator[None]: + self.statement_stack.append(statement) + try: + yield + finally: + self.statement_stack.pop() + + def _add_import(self, module: ModuleType | str, names: list[str] | None = None): + module_name = _module_name(module) + if names is None: + names = ["*"] + if [ + expression + for expression in self.current_module.body + if isinstance(expression, ImportFrom) + and expression.module == module_name + and set(expression.names) & {"*", *names} + ]: + return + self.current_module.body.insert( + # __future__ imports need to go first + (0 if module_name == "__future__" else 1), + ImportFrom( + module=module_name, + names=([ # type:ignore[no-any-expr] + alias(name=name, asname=None) for name in names + ]), + level=0, + ), + ) + + def _name(self, name: str, *, module: ModuleType | str | None) -> Name: + if module: + self._add_import(module, [name]) + return Name(id=name) + + @override + def start_suite(self, suite: result.TestSuite): + robot_file = _robot_file(suite) + if robot_file is None: + return + module = Module( + body=[], type_ignores=[] # type:ignore[no-any-expr] + ) + self.current_module = module + self._add_import("__future__", ["annotations"]) + self.modules[ + self.output_dir + / (robot_file.parent).relative_to(self.output_dir) + / f"{_pytestify_name(robot_file.stem)}.py" + ] = module + + @override + def end_suite(self, suite: result.TestSuite): + if _robot_file(suite) is not None: + del self.current_module + + @override + def visit_test(self, test: result.TestCase): + test_function = FunctionDef( + name=_pytestify_name(test.name), + args=[], # type:ignore[no-any-expr] + decorator_list=[], # type:ignore[no-any-expr] + body=[], # type:ignore[no-any-expr] + lineno=-1, + ) + self.current_module.body.append(test_function) + with self._stack_frame(test_function): + super().visit_test(test) + + @override + # https://github.com/robotframework/robotframework/issues/4940 + def visit_keyword(self, keyword: result.Keyword): # type:ignore[override] + if not keyword.name: + raise UserError("why yo keyword aint got no name") + strcomputer = "." + call: expr | None = None + + def create_call(function: str, module: ModuleType | str | None = None) -> Call: + return Call( + func=self._name(function, module=module), + args=[ + # CRINGE: i cbf figureing out how to properly convert robot variables for now so + # just turn them all into fstrings in the jankiest way possible + parse(f"f{arg.replace('${{', '{{')!r}").body[0] + for arg in keyword.args # type:ignore[no-any-expr] + ], + # TODO + keywords=[], # type:ignore[no-any-expr] + ) + + library_name, _, keyword_name = keyword.name.rpartition(strcomputer) + function_name = _pythonify_name(keyword_name) + module_name: str | None = None + if strcomputer in keyword.name: + # if there's a . that means it was imported from some other module: + if library_name in STDLIBS: + if function_name == "no_operation": + call = Constant(value=...) + elif function_name == "log": + call = create_call("info", logger) + module_name = f"robot.libraries.{library_name}" + else: + module_name = _pythonify_name(library_name) + keyword_function = None + else: + # otherwise assume it was defined in this robot file + keyword_function = FunctionDef( + name=_pythonify_name(function_name), + args=arguments( + # TODO: is there a way to get the positional arg names? + args=[ + arg(arg=f"arg{index}") + for index, _ in enumerate( + keyword.args + ) # type:ignore[no-any-expr] + ] + ), + decorator_list=[ + self._name("keyword", module=pytest_robotframework) + ], # type:ignore[no-any-expr] + body=[], # type:ignore[no-any-expr] + lineno=-1, + ) + self.current_module.body.insert( + # insert the keyword before the first test + next( + index + for index, statement in enumerate(self.current_module.body) + if isinstance(statement, FunctionDef) + ), + keyword_function, + ) + if not call: + call = create_call(function_name, module_name) + cast(FunctionDef, self.context).body.append(Expr(call)) + + with self._stack_frame(keyword_function) if keyword_function else nullcontext(): + super().visit_keyword(keyword) + + +def _convert(suite: Path, output: Path) -> dict[Path, str]: + suite = suite.resolve() + output = output.resolve() + robot_2_python = Robot2Python(output) + # ideally we'd set output and log to None since they aren't used, but theyu're needed for the + # prerebotmodifier to run: + with TemporaryDirectory() as output_dir: + RobotFramework().main( # type:ignore[no-untyped-call] + [suite], # type:ignore[no-any-expr] + dryrun=True, + listener=Robot2PythonListener(), + prerebotmodifier=robot_2_python, + runemptysuite=True, + outputdir=output_dir, + report=None, + exitonerror=True, + ) + return {path: unparse(module) for path, module in robot_2_python.modules.items()} + + +def main(): + def inner(suite: Path, output: Path): + for path, module_text in _convert(suite, output).items(): + if not path.parent.exists(): + path.parent.mkdir(parents=True) + path.write_text(module_text) + + run(inner) diff --git a/pytest_robotframework/_internal/utils.py b/pytest_robotframework/_internal/utils.py index 06b931e4..3227ae45 100644 --- a/pytest_robotframework/_internal/utils.py +++ b/pytest_robotframework/_internal/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from contextlib import AbstractContextManager from functools import wraps from typing import TYPE_CHECKING, Callable, Generic, Type, Union, cast @@ -11,6 +12,7 @@ if TYPE_CHECKING: from abc import abstractmethod + from ast import AST from types import TracebackType from pytest import Item, Session, StashKey @@ -84,6 +86,17 @@ class ContextManager(Generic[out_T], AbstractContextManager): pass +# ast.unparse doesn't exist in 3.8 +if sys.version_info < (3, 9): + from astunparse import unparse as unparse_ + + def unparse(ast: AST) -> str: + return cast(str, unparse_(ast)) # type:ignore[no-untyped-call] + +else: + from ast import unparse as unparse + + def init_stash( # pylint:disable=missing-param-doc key: StashKey[T], initializer: Callable[[], T], diff --git a/tests/fixtures/test_robot2python/function_from_library/foo.robot b/tests/fixtures/test_robot2python/function_from_library/foo.robot new file mode 100644 index 00000000..c7aecb7f --- /dev/null +++ b/tests/fixtures/test_robot2python/function_from_library/foo.robot @@ -0,0 +1,7 @@ +*** Settings *** +Library typing + + +*** Test Cases *** +Foo + Cast str asdf diff --git a/tests/fixtures/test_robot2python/function_from_library/test_foo.py b/tests/fixtures/test_robot2python/function_from_library/test_foo.py new file mode 100644 index 00000000..890e9dd5 --- /dev/null +++ b/tests/fixtures/test_robot2python/function_from_library/test_foo.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import cast + + +def test_foo(): + cast("str", "asdf") diff --git a/tests/fixtures/test_robot2python/log/foo.robot b/tests/fixtures/test_robot2python/log/foo.robot new file mode 100644 index 00000000..5ac25ebf --- /dev/null +++ b/tests/fixtures/test_robot2python/log/foo.robot @@ -0,0 +1,3 @@ +*** Test Cases *** +Foo + Log hi diff --git a/tests/fixtures/test_robot2python/log/test_foo.py b/tests/fixtures/test_robot2python/log/test_foo.py new file mode 100644 index 00000000..c7b42dca --- /dev/null +++ b/tests/fixtures/test_robot2python/log/test_foo.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from robot.api.logger import info + + +def test_foo(): + info("hi") # type:ignore[no-untyped-call] diff --git a/tests/fixtures/test_robot2python/no_operation/foo.robot b/tests/fixtures/test_robot2python/no_operation/foo.robot new file mode 100644 index 00000000..e98af1b4 --- /dev/null +++ b/tests/fixtures/test_robot2python/no_operation/foo.robot @@ -0,0 +1,3 @@ +*** Test Cases *** +Foo + No Operation diff --git a/tests/fixtures/test_robot2python/no_operation/test_foo.py b/tests/fixtures/test_robot2python/no_operation/test_foo.py new file mode 100644 index 00000000..4431ef63 --- /dev/null +++ b/tests/fixtures/test_robot2python/no_operation/test_foo.py @@ -0,0 +1,4 @@ +from __future__ import annotations + + +def test_foo(): ... diff --git a/tests/fixtures/test_robot2python/robot_keyword/foo.robot b/tests/fixtures/test_robot2python/robot_keyword/foo.robot new file mode 100644 index 00000000..b9f1ebee --- /dev/null +++ b/tests/fixtures/test_robot2python/robot_keyword/foo.robot @@ -0,0 +1,9 @@ +*** Test Cases *** +Foo + Bar hi + + +*** Keywords *** +Bar + [Arguments] ${a} + Log ${a} diff --git a/tests/fixtures/test_robot2python/robot_keyword/test_foo.py b/tests/fixtures/test_robot2python/robot_keyword/test_foo.py new file mode 100644 index 00000000..34aff2c9 --- /dev/null +++ b/tests/fixtures/test_robot2python/robot_keyword/test_foo.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from robot.api.logger import info + +from pytest_robotframework import keyword + + +@keyword +def bar(arg1: str): + info(f"{arg1}") # type:ignore[no-untyped-call] + + +def test_foo(): + bar(f"hi") diff --git a/tests/test_robot2python.py b/tests/test_robot2python.py new file mode 100644 index 00000000..46d9238d --- /dev/null +++ b/tests/test_robot2python.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from ast import parse +from os import listdir +from pathlib import Path + +from pytest import mark + +from pytest_robotframework._internal.scripts.robot2python import _convert +from pytest_robotframework._internal.utils import unparse + + +def format_code(code: str) -> str: + """hacky way to format code however the ast unparser does. would use black but it's a pain to + run programmatically""" + return unparse(parse(code)) + + +current_file = Path(__file__) +fixtures_folder = current_file.parent / f"fixtures/{current_file.stem}" +suite_names = listdir(fixtures_folder) + + +@mark.parametrize( + "suite", [fixtures_folder / suite for suite in suite_names], ids=suite_names +) +def test_robot2python(suite: Path): + converted_tests = _convert(suite, suite) + for expected_python_file, actual_python_code in converted_tests.items(): + assert format_code(expected_python_file.read_text()) == format_code( + actual_python_code + ) + assert len(converted_tests) == len(list(suite.glob("**/*.py")))