Skip to content

Commit

Permalink
add documentation about robot2python
Browse files Browse the repository at this point in the history
  • Loading branch information
DetachHead committed Nov 27, 2023
1 parent 0aa9847 commit 4c7d16d
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 31 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ 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.

- because the script doesn't know what exactly what keywords are being imported from each library:

- outside of some special cased keywords in the robot `BuiltIn` library, keywords are currently not resolved, so robot `Library` imports are converted to star-imports (ie. `Library library_name` -> `from library_name import *`).

star imports [should not be used](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/), so you should manually update them to import individual keywords explicitly.

- 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:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -278,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.*']
Expand Down
107 changes: 78 additions & 29 deletions pytest_robotframework/_internal/scripts/robot2python.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Iterator, cast

from robot import running
Expand All @@ -23,13 +24,14 @@
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
from robot import model, result


def _pythonify_name(name: str) -> str:
Expand All @@ -40,6 +42,10 @@ 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})")
Expand Down Expand Up @@ -67,7 +73,7 @@ def _stack_frame(self, statement: stmt) -> Iterator[None]:
self.statement_stack.pop()

def _add_import(self, module: ModuleType | str, names: list[str] | None = None):
module_name = module if isinstance(module, str) else module.__name__
module_name = _module_name(module)
if names is None:
names = ["*"]
self.current_module.body.insert(
Expand All @@ -82,8 +88,19 @@ def _add_import(self, module: ModuleType | str, names: list[str] | None = None):
),
)

def _name(self, name: str, *, module: ModuleType | str | None) -> Name:
if module and not [
expression
for expression in self.current_module.body
if isinstance(expression, ImportFrom)
and expression.module == _module_name(module)
and set(expression.names) & {"*", name}
]:
self._add_import(module, [name])
return Name(id=name)

@override
def start_suite(self, suite: running.TestSuite):
def start_suite(self, suite: result.TestSuite):
robot_file = _robot_file(suite)
if robot_file is None:
return
Expand All @@ -97,40 +114,48 @@ def start_suite(self, suite: running.TestSuite):
/ (robot_file.parent).relative_to(self.output_dir)
/ f"{_pytestify_name(robot_file.stem)}.py"
] = module
for module in cast(ItemList[running.model.Import], suite.resource.imports):
self._add_import(module.name)
# for library in cast(ItemList[running.model.Import], suite.resource.imports):
# self._add_import(library.name)
# for keyword in suite.resource.keywords:
# module.body.append(
# FunctionDef(
# name=_pythonify_name(keyword.name),
# 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,
# )
# )

@override
def end_suite(self, suite: running.TestSuite):
# make sure no tests are actually executed once this is done
suite.tests.clear() # type:ignore[no-untyped-call]
def end_suite(self, suite: result.TestSuite):
if _robot_file(suite) is not None:
del self.current_module

@override
def visit_test(self, test: running.TestCase):
function = FunctionDef(
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(function)
with self._stack_frame(function):
self.current_module.body.append(test_function)
with self._stack_frame(test_function):
super().visit_test(test)

# eventually this will add imports to self.current_module
def _resolve_call(self, keyword: running.Keyword) -> expr:
def _resolve_call(self, keyword: result.Keyword) -> expr:
if not keyword.name:
raise UserError("why yo keyword aint got no name")
python_name = _pythonify_name(keyword.name)

def create_call(function: str, module: ModuleType | None = None) -> Call:
if module:
self._add_import(module, [function])
return Call(
func=Name(id=function),
func=self._name(function, module=module),
args=[
Constant(value=arg, kind=None)
for arg in keyword.args # type:ignore[no-any-expr]
Expand All @@ -147,26 +172,50 @@ def create_call(function: str, module: ModuleType | None = None) -> Call:

@override
# https://github.com/robotframework/robotframework/issues/4940
def visit_keyword(self, keyword: running.Keyword): # type:ignore[override]
function = cast(FunctionDef, self.current_module.body[-1])
function.body.append(Expr(self._resolve_call(keyword)))
with self._stack_frame(function):
def visit_keyword(self, keyword: result.Keyword): # type:ignore[override]
if not keyword.name:
raise UserError("why yo keyword aint got no name")
fully_qualified_name = _pythonify_name(keyword.name)
parent_function = cast(FunctionDef, self.context)
strcomputer = "."
if strcomputer in fully_qualified_name:
# if there's a . that means it was imported from some other module:
module_name, _, function_name = fully_qualified_name.rpartition(strcomputer)
self._add_import(module=module_name, names=[function_name])
else:
# otherwise assume it was defined in this robot file
self.current_module.body.append(
FunctionDef(
name=_pythonify_name(fully_qualified_name),
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,
)
)
parent_function.body.append(Expr(self._resolve_call(keyword)))
with self._stack_frame(parent_function):
super().visit_keyword(keyword)


def _convert(suite: Path, output: Path) -> dict[Path, str]:
suite = suite.resolve()
output = output.resolve()
robot_2_python = Robot2Python(output)
RobotFramework().main( # type:ignore[no-untyped-call]
[suite], # type:ignore[no-any-expr]
prerunmodifier=robot_2_python,
runemptysuite=True,
report=None,
output=None,
log=None,
exitonerror=True,
)
# 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,
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()}


Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/test_robot2python/robot_keyword/foo.robot
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
*** Test Cases ***
Foo
Bar hi bye


*** Keywords ***
Bar
[Arguments] ${a} ${b}
Log ${a} ${b}
13 changes: 13 additions & 0 deletions tests/fixtures/test_robot2python/robot_keyword/test_foo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

from logging import info

from pytest_robotframework import keyword


@keyword
def bar(a: str, b: str):
info(a, b)


def test_foo(): ...

0 comments on commit 4c7d16d

Please sign in to comment.