Skip to content

Commit efabd7a

Browse files
bhirszmnojek
andauthored
Allow to import whole external modules with transformers (#483)
* Allow to import whole external modulues with transformers * Apply suggestions from code review Co-authored-by: Mateusz Nojek <matnojek@gmail.com>
1 parent 2644936 commit efabd7a

File tree

14 files changed

+301
-98
lines changed

14 files changed

+301
-98
lines changed

docs/releasenotes/3.6.0.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,36 @@ This release introduces new option ``--load-transformer`` which imports custom t
4848
It is also possible to pass transformer configuration either using this option or through ``--configure``::
4949

5050
> robotidy -c ExtClass1.py:param=value --load-transformer ExtClass2.py:param2=value test.robot
51+
52+
Load custom transformers from the module
53+
-------------------------------------------
54+
55+
It is now possible to load transformers from the Python module. Importing transformers from module works similarly
56+
to how custom libraries are imported in Robot Framework. If the file has the same name as transformer, it will
57+
be auto-imported. The following command::
58+
59+
> robotidy --load-transformer CustomClass.py
60+
61+
will load ``class CustomClass`` from the ``CustomClass.py`` file. It's the old behaviour and it will not change.
62+
63+
If the file does not contain class with the same name, Robotidy will load all transformers from the file (using the
64+
same logic as importing the module).
65+
66+
If you use directory or Python module, Robotidy will check the ``__init__.py`` file inside. By default it will import
67+
all transformers from the ``__init__.py`` file::
68+
69+
from robotidy.transformers import Transformer
70+
71+
from other_file import TransformerB
72+
73+
class TransformerA(Transformer)
74+
75+
will import ``TransformerB`` and ``TransformerA`` (it doesn't need to be defined in ``__init__.py``, it's enough that it's imported).
76+
77+
The order of defining will be used as execution order. If you want to use different order you can define ``TRANSFORMERS``
78+
list in the ``__init__.py``::
79+
80+
TRANSFORMERS = [
81+
"TransformerA",
82+
"TransformerB"
83+
]

docs/source/external_transformers.rst

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,47 @@ You can use both ``--transform`` and ``--load-transformer`` options to load cust
1515
is that ``--transform`` works like include and will only run transformers listed with ``--transform``. While ``--load-transformer``
1616
will run default transformers first and then user transformers.
1717

18-
You can use the same syntax ``robotidy`` is using for developing internal transformers. The name of the file should
19-
be the same as name of the class containing your transformer. Your transformer should inherit from ``robot.api.parsing.ModelTransformer``
20-
parent class.
18+
Importing whole modules
19+
---------------------------
20+
21+
Importing transformers from module works similarly to how custom libraries are imported in Robot Framework. If the the
22+
file has the same name as transformer it will be auto imported. For example following import::
23+
24+
robotidy --load-transformer CustomFormatter.py src
25+
26+
will auto import class ``CustomFormatter`` from the file.
27+
28+
If the file does not contain class with the same name, it is directory, or it is Python module with ``__init__.py`` file
29+
Robotidy will import multiple transformers. By default it imports every class that inherits from
30+
``robot.api.api.parsing.ModelTransformer`` or ``robotidy.transformers.Transformer`` and executes them in order they
31+
were imported.
32+
33+
Following ``__init__.py``:
34+
35+
.. code-block:: python
36+
37+
from robotidy.transformers import Transformer
38+
39+
from other_file import TransformerB
40+
41+
class TransformerA(Transformer)
42+
43+
will import TransformerB and TransformerA.
44+
45+
If you want to use different order you can define ``TRANSFORMERS`` list in the ``__init__.py``:
46+
47+
.. code-block:: python
48+
49+
TRANSFORMERS = [
50+
"TransformerA",
51+
"TransformerB"
52+
]
53+
54+
Transformer development
55+
---------------------------
56+
57+
You can use the same syntax ``robotidy`` is using for developing internal transformers. Your transformer should inherit
58+
from ``robotidy.transformers.Transformer`` or ``robot.api.parsing.ModelTransformer`` parent class.
2159

2260
.. code-block:: python
2361

robotidy/transformers/__init__.py

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
then add ``ENABLED = False`` class attribute inside.
1010
"""
1111
import copy
12+
import inspect
1213
import pathlib
1314
import textwrap
1415
from itertools import chain
15-
from typing import Dict, List, Optional
16+
from typing import Dict, Iterable, List, Optional
1617

1718
try:
1819
import rich_click as click
@@ -132,6 +133,13 @@ def add_transformer(self, tr):
132133
else:
133134
self.transformers[tr.name] = tr
134135

136+
def get_args(self, *names) -> Dict:
137+
for name in names:
138+
name = str(name)
139+
if name in self.transformers:
140+
return self.transformers[name].args
141+
return dict()
142+
135143
def transformer_should_be_included(self, name: str) -> bool:
136144
"""
137145
Check whether --transform option was used. If it was, check if transformer name was used with --transform.
@@ -170,6 +178,9 @@ def validate_config_names(self):
170178
Assert that all --configure NAME are either defaults or from --transform/--load-transformer.
171179
Otherwise raise an error with similar names.
172180
"""
181+
# TODO: Currently not used. It enforces that every --config NAME is valid one which may not be desired
182+
# if the NAME is external transformer which may not be imported.
183+
# Maybe we can add special flag like --validate-config that would run this method if needed.
173184
for transf_name, transformer in self.transformers.items():
174185
if not transformer.is_config_only:
175186
continue
@@ -213,11 +224,12 @@ class TransformerContainer:
213224
Stub for transformer container class that holds the transformer instance and its metadata.
214225
"""
215226

216-
def __init__(self, instance, argument_names, spec):
227+
def __init__(self, instance, argument_names, spec, args):
217228
self.instance = instance
218229
self.name = instance.__class__.__name__
219230
self.enabled_by_default = getattr(instance, "ENABLED", True)
220231
self.parameters = self.get_parameters(argument_names, spec)
232+
self.args = args
221233

222234
def get_parameters(self, argument_names, spec):
223235
params = []
@@ -264,23 +276,66 @@ def get_absolute_path_to_transformer(name, short_name):
264276
return name
265277

266278

267-
def import_transformer(name, args, skip):
268-
short_name = get_transformer_short_name(name)
269-
name = get_absolute_path_to_transformer(name, short_name)
279+
def load_transformers_from_module(module):
280+
classes = inspect.getmembers(module, inspect.isclass)
281+
transformers = dict()
282+
for name, transformer_class in classes:
283+
if issubclass(transformer_class, (Transformer, ModelTransformer)) and transformer_class not in (
284+
Transformer,
285+
ModelTransformer,
286+
):
287+
transformers[name] = transformer_class
288+
return transformers
289+
290+
291+
def order_transformers(transformers, module):
292+
"""If the module contains TRANSFORMERS list, order transformers using this list."""
293+
transform_list = getattr(module, "TRANSFORMERS", [])
294+
if not (transform_list and isinstance(transform_list, list)):
295+
return transformers
296+
ordered_transformers = dict()
297+
for name in transform_list:
298+
if name not in transformers:
299+
raise ImportTransformerError(
300+
f"Importing transformer '{name}' declared in TRANSFORMERS list failed. "
301+
"Verify if correct name was provided."
302+
) from None
303+
ordered_transformers[name] = transformers[name]
304+
return ordered_transformers
305+
306+
307+
def import_transformer(name, config: TransformConfigMap, skip) -> Iterable[TransformerContainer]:
308+
import_path = resolve_core_import_path(name)
309+
short_name = get_transformer_short_name(import_path)
310+
name = get_absolute_path_to_transformer(import_path, short_name)
270311
try:
271-
imported_class = IMPORTER.import_class_or_module(name)
272-
spec = IMPORTER._get_arg_spec(imported_class)
273-
handles_skip = getattr(imported_class, "HANDLES_SKIP", {})
274-
positional, named, argument_names = resolve_args(short_name, spec, args, skip, handles_skip=handles_skip)
312+
imported = IMPORTER.import_class_or_module(name)
313+
if inspect.isclass(imported):
314+
yield create_transformer_instance(
315+
imported, short_name, config.get_args(name, short_name, import_path), skip
316+
)
317+
else:
318+
transformers = load_transformers_from_module(imported)
319+
transformers = order_transformers(transformers, imported)
320+
for name, transformer_class in transformers.items():
321+
yield create_transformer_instance(
322+
transformer_class, name, config.get_args(name, short_name, import_path), skip
323+
)
275324
except DataError:
276325
similar_finder = RecommendationFinder()
277326
similar = similar_finder.find_similar(short_name, TRANSFORMERS)
278327
raise ImportTransformerError(
279328
f"Importing transformer '{short_name}' failed. "
280329
f"Verify if correct name or configuration was provided.{similar}"
281330
) from None
331+
332+
333+
def create_transformer_instance(imported_class, short_name, args, skip):
334+
spec = IMPORTER._get_arg_spec(imported_class)
335+
handles_skip = getattr(imported_class, "HANDLES_SKIP", {})
336+
positional, named, argument_names = resolve_args(short_name, spec, args, skip, handles_skip=handles_skip)
282337
instance = imported_class(*positional, **named)
283-
return TransformerContainer(instance, argument_names, spec)
338+
return TransformerContainer(instance, argument_names, spec, args)
284339

285340

286341
def split_args_to_class_and_skip(args):
@@ -373,11 +428,6 @@ def resolve_core_import_path(name):
373428
return f"robotidy.transformers.{name}" if name in TRANSFORMERS else name
374429

375430

376-
def load_transformer(name, args, skip) -> Optional[TransformerContainer]:
377-
import_name = resolve_core_import_path(name)
378-
return import_transformer(import_name, args, skip)
379-
380-
381431
def can_run_in_robot_version(transformer, overwritten, target_version):
382432
if not hasattr(transformer, "MIN_VERSION"):
383433
return True
@@ -412,31 +462,30 @@ def load_transformers(
412462
"""Dynamically load all classes from this file with attribute `name` defined in selected_transformers"""
413463
loaded_transformers = []
414464
transformers_config.update_with_defaults(TRANSFORMERS)
415-
transformers_config.validate_config_names()
416465
if not force_order:
417466
transformers_config.order_using_list(TRANSFORMERS)
418467
for name, transformer_config in transformers_config.transformers.items():
419468
if not allow_disabled and not transformers_config.transformer_should_be_included(name):
420469
continue
421-
container = load_transformer(name, transformer_config.args, skip)
422-
if transformers_config.force_included_only:
423-
enabled = transformer_config.args.get("enabled", True)
424-
else:
425-
if "enabled" in transformer_config.args:
426-
enabled = transformer_config.args["enabled"]
470+
for container in import_transformer(name, transformers_config, skip):
471+
if transformers_config.force_included_only:
472+
enabled = container.args.get("enabled", True)
427473
else:
428-
enabled = getattr(container.instance, "ENABLED", True)
429-
if not (enabled or allow_disabled):
430-
continue
431-
if can_run_in_robot_version(
432-
container.instance,
433-
overwritten=transformers_config.transformer_was_forcefully_enabled(name),
434-
target_version=target_version,
435-
):
436-
container.enabled_by_default = enabled
437-
loaded_transformers.append(container)
438-
elif allow_version_mismatch and allow_disabled:
439-
setattr(container.instance, "ENABLED", False)
440-
container.enabled_by_default = False
441-
loaded_transformers.append(container)
474+
if "enabled" in container.args:
475+
enabled = container.args["enabled"]
476+
else:
477+
enabled = getattr(container.instance, "ENABLED", True)
478+
if not (enabled or allow_disabled):
479+
continue
480+
if can_run_in_robot_version(
481+
container.instance,
482+
overwritten=transformers_config.transformer_was_forcefully_enabled(name),
483+
target_version=target_version,
484+
):
485+
container.enabled_by_default = enabled
486+
loaded_transformers.append(container)
487+
elif allow_version_mismatch and allow_disabled:
488+
setattr(container.instance, "ENABLED", False)
489+
container.enabled_by_default = False
490+
loaded_transformers.append(container)
442491
return loaded_transformers
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .custom_class import CustomClass1, CustomClass2, Transformer
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from robot.api import Token
2+
3+
from robotidy.transformers import Transformer
4+
5+
6+
class CustomClass1(Transformer):
7+
def visit_SettingSection(self, node):
8+
node.header.data_tokens[0].value = node.header.data_tokens[0].value.lower()
9+
return node
10+
11+
12+
class CustomClass2(Transformer):
13+
def __init__(self, extra_param: bool = False):
14+
self.extra_param = extra_param
15+
super().__init__()
16+
17+
def visit_TestCaseName(self, node): # noqa
18+
"""If extra_param is set to True, lower case the test case name."""
19+
if not self.extra_param:
20+
return node
21+
token = node.get_token(Token.TESTCASE_NAME)
22+
token.value = token.value.lower()
23+
return node
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .custom_class import CustomClass1
2+
from .custom_class2 import CustomClass2, ModelTransformer
3+
4+
# ModelTransformed is imported only to verify it will be not recognized as transformer itself
5+
6+
# CustomClass1 lower case, and CustomClass2 upper case of Settings section header
7+
TRANSFORMERS = ["CustomClass2", "CustomClass1"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from robotidy.transformers import Transformer
2+
3+
4+
class CustomClass1(Transformer):
5+
def visit_SettingSection(self, node):
6+
node.header.data_tokens[0].value = node.header.data_tokens[0].value.lower()
7+
return node
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from robot.api.parsing import ModelTransformer
2+
3+
4+
class CustomClass2(ModelTransformer):
5+
def visit_SettingSection(self, node):
6+
node.header.data_tokens[0].value = node.header.data_tokens[0].value.upper()
7+
return node
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*** settings ***
2+
Library KeywordLibrary
3+
4+
5+
*** Test Cases ***
6+
Test
7+
Pass
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*** settings ***
2+
Library KeywordLibrary
3+
4+
5+
*** Test Cases ***
6+
test
7+
Pass

0 commit comments

Comments
 (0)