Skip to content

Commit

Permalink
JSON serialization support to resource files.
Browse files Browse the repository at this point in the history
This required some changes to the resource file model:

- Add ModelObject base class to ResourceFile, UserKeyword, Import and
  Variable model objects. This adds generic JSON serialization methods
  and brings some nice features like better repr() as a bonus.

- Change import types from 'Library', 'Resource' and 'Variables' to
  'LIBRARY', 'RESOURCE' and 'VARIABLES', respectively. We've used
  upper case types also elsewhere. Also add matching class attributes
  to allow using constants instead of stringly typing.

- Fix code expecting old style type names and clean up related code in
  general.

Part of #3902.
  • Loading branch information
pekkaklarck committed Jan 10, 2023
1 parent 6316243 commit d81386e
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 84 deletions.
1 change: 1 addition & 0 deletions src/robot/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .itemlist import ItemList
from .keyword import Keyword, Keywords
from .message import Message, Messages
from .modelobject import ModelObject
from .modifier import ModelModifier
from .namepatterns import SuiteNamePatterns, TestNamePatterns
from .statistics import Statistics
Expand Down
24 changes: 8 additions & 16 deletions src/robot/running/builder/transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,14 @@ def visit_KeywordTags(self, node):
def visit_TestTemplate(self, node):
self.defaults.template = node.value

def visit_ResourceImport(self, node):
self.suite.resource.imports.create(type='Resource', name=node.name,
lineno=node.lineno)

def visit_LibraryImport(self, node):
self.suite.resource.imports.create(type='Library', name=node.name,
args=node.args, alias=node.alias,
lineno=node.lineno)
self.suite.resource.imports.library(node.name, node.args, node.alias, node.lineno)

def visit_ResourceImport(self, node):
self.suite.resource.imports.resource(node.name, node.lineno)

def visit_VariablesImport(self, node):
self.suite.resource.imports.create(type='Variables', name=node.name,
args=node.args, lineno=node.lineno)
self.suite.resource.imports.variables(node.name, node.args, node.lineno)

def visit_VariableSection(self, node):
pass
Expand Down Expand Up @@ -124,17 +120,13 @@ def visit_KeywordTags(self, node):
self.defaults.keyword_tags = node.values

def visit_LibraryImport(self, node):
self.resource.imports.create(type='Library', name=node.name,
args=node.args, alias=node.alias,
lineno=node.lineno)
self.resource.imports.library(node.name, node.args, node.alias, node.lineno)

def visit_ResourceImport(self, node):
self.resource.imports.create(type='Resource', name=node.name,
lineno=node.lineno)
self.resource.imports.resource(node.name, node.lineno)

def visit_VariablesImport(self, node):
self.resource.imports.create(type='Variables', name=node.name,
args=node.args, lineno=node.lineno)
self.resource.imports.variables(node.name, node.args, node.lineno)

def visit_Variable(self, node):
self.resource.variables.create(name=node.name,
Expand Down
202 changes: 169 additions & 33 deletions src/robot/running/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@

from robot import model
from robot.conf import RobotSettings
from robot.errors import BreakLoop, ContinueLoop, ReturnFromKeyword, DataError
from robot.model import Keywords, BodyItem
from robot.errors import BreakLoop, ContinueLoop, DataError, ReturnFromKeyword
from robot.model import BodyItem, create_fixture, Keywords, ModelObject
from robot.output import LOGGER, Output, pyloggingconf
from robot.result import (Break as BreakResult, Continue as ContinueResult,
Return as ReturnResult)
from robot.utils import seq2str, setter
from robot.utils import setter

from .bodyrunner import ForRunner, IfRunner, KeywordRunner, TryRunner, WhileRunner
from .randomizer import Randomizer
Expand Down Expand Up @@ -353,7 +353,7 @@ class TestSuite(model.TestSuite):
See the base class for documentation of attributes not documented here.
"""
__slots__ = ['resource']
__slots__ = []
test_class = TestCase #: Internal usage only.
fixture_class = Keyword #: Internal usage only.

Expand All @@ -362,7 +362,13 @@ def __init__(self, name='', doc='', metadata=None, source=None, rpa=None):
#: :class:`ResourceFile` instance containing imports, variables and
#: keywords the suite owns. When data is parsed from the file system,
#: this data comes from the same test case file that creates the suite.
self.resource = ResourceFile(source=source)
self.resource = ResourceFile(source)

@setter
def resource(self, resource):
if isinstance(resource, dict):
resource = ResourceFile.from_dict(resource)
return resource

@classmethod
def from_file_system(cls, *paths, **config):
Expand Down Expand Up @@ -495,48 +501,90 @@ def run(self, settings=None, **options):
output.close(runner.result)
return runner.result

def to_dict(self):
data = super().to_dict()
data['resource'] = self.resource.to_dict()
return data


class Variable:
class Variable(ModelObject):
repr_args = ('name', 'value')

def __init__(self, name, value, source=None, lineno=None, error=None):
def __init__(self, name, value, parent=None, lineno=None, error=None):
self.name = name
self.value = value
self.source = source
self.parent = parent
self.lineno = lineno
self.error = error

@property
def source(self):
return self.parent.source if self.parent is not None else None

def report_invalid_syntax(self, message, level='ERROR'):
source = self.source or '<unknown>'
line = f' on line {self.lineno}' if self.lineno else ''
LOGGER.write(f"Error in file '{source}'{line}: "
f"Setting variable '{self.name}' failed: {message}", level)

@classmethod
def from_dict(cls, data):
return cls(**data)

class ResourceFile:
def to_dict(self):
data = {'name': self.name, 'value': self.value}
if self.lineno:
data['lineno'] = self.lineno
if self.error:
data['error'] = self.error
return data

def __init__(self, doc='', source=None):
self.doc = doc

class ResourceFile(ModelObject):
repr_args = ('source',)
__slots__ = ('source', 'doc')

def __init__(self, source=None, doc=''):
self.source = source
self.doc = doc
self.imports = []
self.keywords = []
self.variables = []
self.keywords = []

@setter
def imports(self, imports):
return Imports(self.source, imports)
return Imports(self, imports)

@setter
def variables(self, variables):
return model.ItemList(Variable, {'parent': self}, items=variables)

@setter
def keywords(self, keywords):
return model.ItemList(UserKeyword, {'parent': self}, items=keywords)

@setter
def variables(self, variables):
return model.ItemList(Variable, {'source': self.source}, items=variables)
def to_dict(self):
data = {}
if self.source:
data['source'] = self.source
if self.doc:
data['doc'] = self.doc
if self.imports:
data['imports'] = self.imports.to_dicts()
if self.variables:
data['variables'] = self.variables.to_dicts()
if self.keywords:
data['keywords'] = self.keywords.to_dicts()
return data


class UserKeyword:
class UserKeyword(ModelObject):
repr_args = ('name', 'args')
fixture_class = Keyword
__slots__ = ['name', 'args', 'doc', 'return_', 'timeout', 'lineno', 'parent',
'error', '_teardown']

def __init__(self, name, args=(), doc='', tags=(), return_=None,
def __init__(self, name='', args=(), doc='', tags=(), return_=None,
timeout=None, lineno=None, parent=None, error=None):
self.name = name
self.args = args
Expand Down Expand Up @@ -573,9 +621,26 @@ def keywords(self, keywords):
@property
def teardown(self):
if self._teardown is None:
self._teardown = Keyword(None, parent=self, type=Keyword.TEARDOWN)
self._teardown = create_fixture(None, self, Keyword.TEARDOWN)
return self._teardown

@teardown.setter
def teardown(self, teardown):
self._teardown = create_fixture(teardown, self, Keyword.TEARDOWN)

@property
def has_teardown(self):
"""Check does a keyword have a teardown without creating a teardown object.
A difference between using ``if uk.has_teardown:`` and ``if uk.teardown:``
is that accessing the :attr:`teardown` attribute creates a :class:`Keyword`
object representing the teardown even when the user keyword actually does
not have one. This can have an effect on memory usage.
New in Robot Framework 6.1.
"""
return bool(self._teardown)

@setter
def tags(self, tags):
return model.Tags(tags)
Expand All @@ -584,21 +649,53 @@ def tags(self, tags):
def source(self):
return self.parent.source if self.parent is not None else None

def to_dict(self):
data = {'name': self.name}
if self.args:
data['args'] = list(self.args)
if self.doc:
data['doc'] = self.doc
if self.tags:
data['tags'] = list(self.tags)
if self.return_:
data['return_'] = self.return_
if self.timeout:
data['timeout'] = self.timeout
if self.lineno:
data['lineno'] = self.lineno
if self.error:
data['error'] = self.error
data['body'] = self.body.to_dicts()
if self.has_teardown:
data['teardown'] = self.teardown.to_dict()
return data


class Import:
ALLOWED_TYPES = ('Library', 'Resource', 'Variables')
class Import(ModelObject):
repr_args = ('type', 'name', 'args', 'alias')
LIBRARY = 'LIBRARY'
RESOURCE = 'RESOURCE'
VARIABLES = 'VARIABLES'

def __init__(self, type, name, args=(), alias=None, source=None, lineno=None):
if type not in self.ALLOWED_TYPES:
raise ValueError(f"Invalid import type '{type}'. Should be one of "
f"{seq2str(self.ALLOWED_TYPES, lastsep=' or ')}.")
def __init__(self, type, name, args=(), alias=None, parent=None, lineno=None):
if type not in (self.LIBRARY, self.RESOURCE, self.VARIABLES):
raise ValueError(f"Invalid import type: Expected '{self.LIBRARY}', "
f"'{self.RESOURCE}' or '{self.VARIABLES}', got '{type}'.")
self.type = type
self.name = name
self.args = args
self.alias = alias
self.source = source
self.parent = parent
self.lineno = lineno

def _repr(self, repr_args):
repr_args = [a for a in repr_args if a in ('type', 'name') or getattr(self, a)]
return super()._repr(repr_args)

@property
def source(self):
return self.parent.source if self.parent is not None else None

@property
def directory(self):
if not self.source:
Expand All @@ -607,22 +704,61 @@ def directory(self):
return self.source
return os.path.dirname(self.source)

@property
def setting_name(self):
return self.type.title()

def select(self, library, resource, variables):
return {self.LIBRARY: library,
self.RESOURCE: resource,
self.VARIABLES: variables}[self.type]

def report_invalid_syntax(self, message, level='ERROR'):
source = self.source or '<unknown>'
line = f' on line {self.lineno}' if self.lineno else ''
LOGGER.write(f"Error in file '{source}'{line}: {message}", level)

@classmethod
def from_dict(cls, data):
return cls(**data)

def to_dict(self):
data = {'type': self.type, 'name': self.name}
if self.args:
data['args'] = list(self.args)
if self.alias:
data['alias'] = self.alias
if self.lineno:
data['lineno'] = self.lineno
return data


class Imports(model.ItemList):

def __init__(self, source, imports=None):
super().__init__(Import, {'source': source}, items=imports)
def __init__(self, parent, imports=None):
super().__init__(Import, {'parent': parent}, items=imports)

def library(self, name, args=(), alias=None, lineno=None):
self.create('Library', name, args, alias, lineno)
"""Create library import."""
self.create(Import.LIBRARY, name, args, alias, lineno=lineno)

def resource(self, name, lineno=None):
"""Create resource import."""
self.create(Import.RESOURCE, name, lineno=lineno)

def resource(self, path, lineno=None):
self.create('Resource', path, lineno)
def variables(self, name, args=(), lineno=None):
"""Create variables import."""
self.create(Import.VARIABLES, name, args, lineno=lineno)

def variables(self, path, args=(), lineno=None):
self.create('Variables', path, args, lineno)
def create(self, *args, **kwargs):
"""Generic method for creating imports.
Import type specific methods :meth:`library`, :meth:`resource` and
:meth:`variables` are recommended over this method.
"""
# RF 6.1 changed types to upper case. Code below adds backwards compatibility.
if args:
args = (args[0].upper(),) + args[1:]
elif 'type' in kwargs:
kwargs['type'] = kwargs['type'].upper()
return super().create(*args, **kwargs)

0 comments on commit d81386e

Please sign in to comment.