# Copyright 2008-2015 Nokia Networks
# Copyright 2016- Robot Framework Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import copy
import warnings
from robot.errors import DataError
from robot.variables import is_var
from robot.output import LOGGER
from robot.writer import DataFileWriter
from robot.utils import abspath, is_string, normalize, py2to3, NormalizedDict
from .comments import Comment
from .populators import FromFilePopulator, FromDirectoryPopulator, NoTestsFound
from .settings import (Documentation, Fixture, Timeout, Tags, Metadata,
Library, Resource, Variables, Arguments, Return,
Template, MetadataList, ImportList)
[docs]def TestData(parent=None, source=None, include_suites=None,
warn_on_skipped='DEPRECATED', extensions=None):
"""Parses a file or directory to a corresponding model object.
:param parent: Optional parent to be used in creation of the model object.
:param source: Path where test data is read from.
:param warn_on_skipped: Deprecated.
:param extensions: List/set of extensions to parse. If None, all files
supported by Robot Framework are parsed when searching test cases.
:returns: :class:`~.model.TestDataDirectory` if `source` is a directory,
:class:`~.model.TestCaseFile` otherwise.
"""
# TODO: Remove in RF 3.2.
if warn_on_skipped != 'DEPRECATED':
warnings.warn("Option 'warn_on_skipped' is deprecated and has no "
"effect.", DeprecationWarning)
if os.path.isdir(source):
return TestDataDirectory(parent, source).populate(include_suites,
extensions)
return TestCaseFile(parent, source).populate()
class _TestData(object):
_setting_table_names = 'Setting', 'Settings'
_variable_table_names = 'Variable', 'Variables'
_testcase_table_names = 'Test Case', 'Test Cases', 'Task', 'Tasks'
_keyword_table_names = 'Keyword', 'Keywords'
_comment_table_names = 'Comment', 'Comments'
def __init__(self, parent=None, source=None):
self.parent = parent
self.source = abspath(source) if source else None
self.children = []
self._tables = dict(self._get_tables())
def _get_tables(self):
for names, table in [(self._setting_table_names, self.setting_table),
(self._variable_table_names, self.variable_table),
(self._testcase_table_names, self.testcase_table),
(self._keyword_table_names, self.keyword_table),
(self._comment_table_names, None)]:
for name in names:
yield name, table
def start_table(self, header_row):
table = self._find_table(header_row)
if table is None or not self._table_is_allowed(table):
return None
table.set_header(header_row)
return table
def _find_table(self, header_row):
name = header_row[0] if header_row else ''
title = name.title()
if title not in self._tables:
title = self._resolve_deprecated_table(name)
if title is None:
self._report_unrecognized_table(name)
return None
return self._tables[title]
def _resolve_deprecated_table(self, used_name):
normalized = normalize(used_name)
for name in (self._setting_table_names + self._variable_table_names +
self._testcase_table_names + self._keyword_table_names +
self._comment_table_names):
if normalize(name) == normalized:
self._report_deprecated_table(used_name, name)
return name
return None
def _report_deprecated_table(self, deprecated, name):
self.report_invalid_syntax(
"Section name '%s' is deprecated. Use '%s' instead."
% (deprecated, name), level='WARN'
)
def _report_unrecognized_table(self, name):
self.report_invalid_syntax(
"Unrecognized table header '%s'. Available headers for data: "
"'Setting(s)', 'Variable(s)', 'Test Case(s)', 'Task(s)' and "
"'Keyword(s)'. Use 'Comment(s)' to embedded additional data."
% name
)
def _table_is_allowed(self, table):
return True
@property
def name(self):
return self._format_name(self._get_basename()) if self.source else None
def _get_basename(self):
return os.path.splitext(os.path.basename(self.source))[0]
def _format_name(self, name):
name = self._strip_possible_prefix_from_name(name)
name = name.replace('_', ' ').strip()
return name.title() if name.islower() else name
def _strip_possible_prefix_from_name(self, name):
return name.split('__', 1)[-1]
@property
def keywords(self):
return self.keyword_table.keywords
@property
def imports(self):
return self.setting_table.imports
def report_invalid_syntax(self, message, level='ERROR'):
initfile = getattr(self, 'initfile', None)
path = os.path.join(self.source, initfile) if initfile else self.source
LOGGER.write("Error in file '%s': %s" % (path, message), level)
def save(self, **options):
"""Writes this datafile to disk.
:param options: Configuration for writing. These are passed to
:py:class:`~robot.writer.datafilewriter.WritingContext` as
keyword arguments.
See also :py:class:`robot.writer.datafilewriter.DataFileWriter`
"""
return DataFileWriter(**options).write(self)
[docs]@py2to3
class TestCaseFile(_TestData):
"""The parsed test case file object.
:param parent: parent object to be used in creation of the model object.
:param source: path where test data is read from.
"""
def __init__(self, parent=None, source=None):
self.directory = os.path.dirname(source) if source else None
self.setting_table = TestCaseFileSettingTable(self)
self.variable_table = VariableTable(self)
self.testcase_table = TestCaseTable(self)
self.keyword_table = KeywordTable(self)
_TestData.__init__(self, parent, source)
[docs] def populate(self):
FromFilePopulator(self).populate(self.source)
self._validate()
return self
def _validate(self):
if not self.testcase_table.is_started():
raise NoTestsFound('File has no tests or tasks.')
[docs] def has_tests(self):
return True
def __iter__(self):
for table in [self.setting_table, self.variable_table,
self.testcase_table, self.keyword_table]:
yield table
def __nonzero__(self):
return any(table for table in self)
[docs]class ResourceFile(_TestData):
"""The parsed resource file object.
:param source: path where resource file is read from.
"""
def __init__(self, source=None):
self.directory = os.path.dirname(source) if source else None
self.setting_table = ResourceFileSettingTable(self)
self.variable_table = VariableTable(self)
self.testcase_table = TestCaseTable(self)
self.keyword_table = KeywordTable(self)
_TestData.__init__(self, source=source)
[docs] def populate(self):
FromFilePopulator(self).populate(self.source, resource=True)
self._report_status()
return self
def _report_status(self):
if self.setting_table or self.variable_table or self.keyword_table:
LOGGER.info("Imported resource file '%s' (%d keywords)."
% (self.source, len(self.keyword_table.keywords)))
else:
LOGGER.warn("Imported resource file '%s' is empty." % self.source)
def _table_is_allowed(self, table):
if table is self.testcase_table:
raise DataError("Resource file '%s' cannot contain tests or "
"tasks." % self.source)
return True
def __iter__(self):
for table in [self.setting_table, self.variable_table, self.keyword_table]:
yield table
[docs]class TestDataDirectory(_TestData):
"""The parsed test data directory object. Contains hiearchical structure
of other :py:class:`.TestDataDirectory` and :py:class:`.TestCaseFile`
objects.
:param parent: parent object to be used in creation of the model object.
:param source: path where test data is read from.
"""
def __init__(self, parent=None, source=None):
self.directory = source
self.initfile = None
self.setting_table = InitFileSettingTable(self)
self.variable_table = VariableTable(self)
self.testcase_table = TestCaseTable(self)
self.keyword_table = KeywordTable(self)
_TestData.__init__(self, parent, source)
[docs] def populate(self, include_suites=None, extensions=None, recurse=True):
FromDirectoryPopulator().populate(self.source, self, include_suites,
extensions, recurse)
self.children = [ch for ch in self.children if ch.has_tests()]
return self
def _get_basename(self):
return os.path.basename(self.source)
def _table_is_allowed(self, table):
if table is self.testcase_table:
LOGGER.error("Test suite initialization file in '%s' cannot "
"contain tests or tasks." % self.source)
return False
return True
[docs] def add_child(self, path, include_suites, extensions=None):
self.children.append(TestData(parent=self,
source=path,
include_suites=include_suites,
extensions=extensions))
[docs] def has_tests(self):
return any(ch.has_tests() for ch in self.children)
def __iter__(self):
for table in [self.setting_table, self.variable_table, self.keyword_table]:
yield table
@py2to3
class _Table(object):
def __init__(self, parent):
self.parent = parent
self._header = None
def set_header(self, header):
self._header = self._prune_old_style_headers(header)
def _prune_old_style_headers(self, header):
if len(header) < 3:
return header
if self._old_header_matcher.match(header):
return [header[0]]
return header
@property
def header(self):
return self._header or [self.type.title() + 's']
@property
def name(self):
return self.header[0]
@property
def source(self):
return self.parent.source
@property
def directory(self):
return self.parent.directory
def report_invalid_syntax(self, message, level='ERROR'):
self.parent.report_invalid_syntax(message, level)
def __nonzero__(self):
return bool(self._header or len(self))
def __len__(self):
return sum(1 for item in self)
class _WithSettings(object):
_setters = {}
_aliases = {}
def get_setter(self, name):
if name[-1:] == ':':
name = name[:-1]
setter = self._get_setter(name)
if setter is not None:
return setter
setter = self._get_deprecated_setter(name)
if setter is not None:
return setter
self.report_invalid_syntax("Non-existing setting '%s'." % name)
return None
def _get_setter(self, name):
title = name.title()
if title in self._aliases:
title = self._aliases[name]
if title in self._setters:
return self._setters[title](self)
return None
def _get_deprecated_setter(self, name):
normalized = normalize(name)
for setting in list(self._setters) + list(self._aliases):
if normalize(setting) == normalized:
self._report_deprecated_setting(name, setting)
return self._get_setter(setting)
return None
def _report_deprecated_setting(self, deprecated, correct):
self.report_invalid_syntax(
"Setting '%s' is deprecated. Use '%s' instead."
% (deprecated, correct), level='WARN'
)
def report_invalid_syntax(self, message, level='ERROR'):
raise NotImplementedError
class _SettingTable(_Table, _WithSettings):
type = 'setting'
def __init__(self, parent):
_Table.__init__(self, parent)
self.doc = Documentation('Documentation', self)
self.suite_setup = Fixture('Suite Setup', self)
self.suite_teardown = Fixture('Suite Teardown', self)
self.test_setup = Fixture('Test Setup', self)
self.test_teardown = Fixture('Test Teardown', self)
self.force_tags = Tags('Force Tags', self)
self.default_tags = Tags('Default Tags', self)
self.test_template = Template('Test Template', self)
self.test_timeout = Timeout('Test Timeout', self)
self.metadata = MetadataList(self)
self.imports = ImportList(self)
@property
def _old_header_matcher(self):
return OldStyleSettingAndVariableTableHeaderMatcher()
def add_metadata(self, name, value='', comment=None):
self.metadata.add(Metadata(self, name, value, comment))
return self.metadata[-1]
def add_library(self, name, args=None, comment=None):
self.imports.add(Library(self, name, args, comment=comment))
return self.imports[-1]
def add_resource(self, name, invalid_args=None, comment=None):
self.imports.add(Resource(self, name, invalid_args, comment=comment))
return self.imports[-1]
def add_variables(self, name, args=None, comment=None):
self.imports.add(Variables(self, name, args, comment=comment))
return self.imports[-1]
def __len__(self):
return sum(1 for setting in self if setting.is_set())
[docs]class TestCaseFileSettingTable(_SettingTable):
_setters = {'Documentation': lambda s: s.doc.populate,
'Suite Setup': lambda s: s.suite_setup.populate,
'Suite Teardown': lambda s: s.suite_teardown.populate,
'Test Setup': lambda s: s.test_setup.populate,
'Test Teardown': lambda s: s.test_teardown.populate,
'Force Tags': lambda s: s.force_tags.populate,
'Default Tags': lambda s: s.default_tags.populate,
'Test Template': lambda s: s.test_template.populate,
'Test Timeout': lambda s: s.test_timeout.populate,
'Library': lambda s: s.imports.populate_library,
'Resource': lambda s: s.imports.populate_resource,
'Variables': lambda s: s.imports.populate_variables,
'Metadata': lambda s: s.metadata.populate}
_aliases = {'Task Setup': 'Test Setup',
'Task Teardown': 'Test Teardown',
'Task Template': 'Test Template',
'Task Timeout': 'Test Timeout'}
def __iter__(self):
for setting in [self.doc, self.suite_setup, self.suite_teardown,
self.test_setup, self.test_teardown, self.force_tags,
self.default_tags, self.test_template, self.test_timeout] \
+ self.metadata.data + self.imports.data:
yield setting
[docs]class ResourceFileSettingTable(_SettingTable):
_setters = {'Documentation': lambda s: s.doc.populate,
'Library': lambda s: s.imports.populate_library,
'Resource': lambda s: s.imports.populate_resource,
'Variables': lambda s: s.imports.populate_variables}
def __iter__(self):
for setting in [self.doc] + self.imports.data:
yield setting
[docs]class InitFileSettingTable(_SettingTable):
_setters = {'Documentation': lambda s: s.doc.populate,
'Suite Setup': lambda s: s.suite_setup.populate,
'Suite Teardown': lambda s: s.suite_teardown.populate,
'Test Setup': lambda s: s.test_setup.populate,
'Test Teardown': lambda s: s.test_teardown.populate,
'Test Timeout': lambda s: s.test_timeout.populate,
'Force Tags': lambda s: s.force_tags.populate,
'Library': lambda s: s.imports.populate_library,
'Resource': lambda s: s.imports.populate_resource,
'Variables': lambda s: s.imports.populate_variables,
'Metadata': lambda s: s.metadata.populate}
def __iter__(self):
for setting in [self.doc, self.suite_setup, self.suite_teardown,
self.test_setup, self.test_teardown, self.force_tags,
self.test_timeout] + self.metadata.data + self.imports.data:
yield setting
[docs]class VariableTable(_Table):
type = 'variable'
def __init__(self, parent):
_Table.__init__(self, parent)
self.variables = []
@property
def _old_header_matcher(self):
return OldStyleSettingAndVariableTableHeaderMatcher()
[docs] def add(self, name, value, comment=None):
self.variables.append(Variable(self, name, value, comment))
def __iter__(self):
return iter(self.variables)
[docs]class TestCaseTable(_Table):
type = 'test case'
def __init__(self, parent):
_Table.__init__(self, parent)
self.tests = []
def _validate_mode(self, name1, name2):
tasks1 = normalize(name1) in ('task', 'tasks')
tasks2 = normalize(name2) in ('task', 'tasks')
if tasks1 is not tasks2:
raise DataError('One file cannot have both tests and tasks.')
@property
def _old_header_matcher(self):
return OldStyleTestAndKeywordTableHeaderMatcher()
[docs] def add(self, name):
self.tests.append(TestCase(self, name))
return self.tests[-1]
def __iter__(self):
return iter(self.tests)
[docs] def is_started(self):
return bool(self._header)
[docs]class KeywordTable(_Table):
type = 'keyword'
def __init__(self, parent):
_Table.__init__(self, parent)
self.keywords = []
@property
def _old_header_matcher(self):
return OldStyleTestAndKeywordTableHeaderMatcher()
[docs] def add(self, name):
self.keywords.append(UserKeyword(self, name))
return self.keywords[-1]
def __iter__(self):
return iter(self.keywords)
[docs]@py2to3
class Variable(object):
def __init__(self, parent, name, value, comment=None):
self.parent = parent
self.name = name.rstrip('= ')
if name.startswith('$') and value == []:
value = ''
if is_string(value):
value = [value]
self.value = value
self.comment = Comment(comment)
[docs] def as_list(self):
if self.has_data():
return [self.name] + self.value + self.comment.as_list()
return self.comment.as_list()
[docs] def is_set(self):
return True
[docs] def is_for_loop(self):
return False
[docs] def has_data(self):
return bool(self.name or ''.join(self.value))
def __nonzero__(self):
return self.has_data()
[docs] def report_invalid_syntax(self, message, level='ERROR'):
self.parent.report_invalid_syntax("Setting variable '%s' failed: %s"
% (self.name, message), level)
class _WithSteps(object):
def add_step(self, content, comment=None):
self.steps.append(Step(content, comment))
return self.steps[-1]
def copy(self, name):
new = copy.deepcopy(self)
new.name = name
self._add_to_parent(new)
return new
[docs]class TestCase(_WithSteps, _WithSettings):
def __init__(self, parent, name):
self.parent = parent
self.name = name
self.doc = Documentation('[Documentation]', self)
self.template = Template('[Template]', self)
self.tags = Tags('[Tags]', self)
self.setup = Fixture('[Setup]', self)
self.teardown = Fixture('[Teardown]', self)
self.timeout = Timeout('[Timeout]', self)
self.steps = []
if name == '...':
self.report_invalid_syntax(
"Using '...' as test case name is deprecated. It will be "
"considered line continuation in Robot Framework 3.2.",
level='WARN'
)
_setters = {'Documentation': lambda s: s.doc.populate,
'Template': lambda s: s.template.populate,
'Setup': lambda s: s.setup.populate,
'Teardown': lambda s: s.teardown.populate,
'Tags': lambda s: s.tags.populate,
'Timeout': lambda s: s.timeout.populate}
@property
def source(self):
return self.parent.source
@property
def directory(self):
return self.parent.directory
[docs] def add_for_loop(self, declaration, comment=None):
self.steps.append(ForLoop(self, declaration, comment))
return self.steps[-1]
[docs] def end_for_loop(self):
loop, steps = self._find_last_empty_for_and_steps_after()
if not loop:
return False
loop.steps.extend(steps)
self.steps[-len(steps):] = []
return True
def _find_last_empty_for_and_steps_after(self):
steps = []
for step in reversed(self.steps):
if isinstance(step, ForLoop):
if not step.steps:
steps.reverse()
return step, steps
break
steps.append(step)
return None, []
[docs] def report_invalid_syntax(self, message, level='ERROR'):
type_ = 'test case' if type(self) is TestCase else 'keyword'
message = "Invalid syntax in %s '%s': %s" % (type_, self.name, message)
self.parent.report_invalid_syntax(message, level)
def _add_to_parent(self, test):
self.parent.tests.append(test)
@property
def settings(self):
return [self.doc, self.tags, self.setup, self.template, self.timeout,
self.teardown]
def __iter__(self):
for element in [self.doc, self.tags, self.setup,
self.template, self.timeout] \
+ self.steps + [self.teardown]:
yield element
[docs]class UserKeyword(TestCase):
def __init__(self, parent, name):
self.parent = parent
self.name = name
self.doc = Documentation('[Documentation]', self)
self.args = Arguments('[Arguments]', self)
self.return_ = Return('[Return]', self)
self.timeout = Timeout('[Timeout]', self)
self.teardown = Fixture('[Teardown]', self)
self.tags = Tags('[Tags]', self)
self.steps = []
if name == '...':
self.report_invalid_syntax(
"Using '...' as keyword name is deprecated. It will be "
"considered line continuation in Robot Framework 3.2.",
level='WARN'
)
_setters = {'Documentation': lambda s: s.doc.populate,
'Arguments': lambda s: s.args.populate,
'Return': lambda s: s.return_.populate,
'Timeout': lambda s: s.timeout.populate,
'Teardown': lambda s: s.teardown.populate,
'Tags': lambda s: s.tags.populate}
def _add_to_parent(self, test):
self.parent.keywords.append(test)
@property
def settings(self):
return [self.args, self.doc, self.tags, self.timeout, self.teardown, self.return_]
def __iter__(self):
for element in [self.args, self.doc, self.tags, self.timeout] \
+ self.steps + [self.teardown, self.return_]:
yield element
[docs]class ForLoop(_WithSteps):
"""The parsed representation of a for-loop.
:param list declaration: The literal cell values that declare the loop
(excluding ":FOR").
:param str comment: A comment, default None.
:ivar str flavor: The value of the 'IN' item, uppercased.
Typically 'IN', 'IN RANGE', 'IN ZIP', or 'IN ENUMERATE'.
:ivar list vars: Variables set per-iteration by this loop.
:ivar list items: Loop values that come after the 'IN' item.
:ivar str comment: A comment, or None.
:ivar list steps: A list of steps in the loop.
"""
flavors = {'IN', 'IN RANGE', 'IN ZIP', 'IN ENUMERATE'}
normalized_flavors = NormalizedDict((f, f) for f in flavors)
def __init__(self, parent, declaration, comment=None):
self.parent = parent
self.flavor, index = self._get_flavor_and_index(declaration)
self.vars = declaration[:index]
self.items = declaration[index+1:]
self.comment = Comment(comment)
self.steps = []
def _get_flavor_and_index(self, declaration):
for index, item in enumerate(declaration):
if item in self.flavors:
return item, index
if item in self.normalized_flavors:
correct = self.normalized_flavors[item]
self._report_deprecated_flavor_syntax(item, correct)
return correct, index
if normalize(item).startswith('in'):
return item.upper(), index
return 'IN', len(declaration)
def _report_deprecated_flavor_syntax(self, deprecated, correct):
self.parent.report_invalid_syntax(
"Using '%s' as a FOR loop separator is deprecated. "
"Use '%s' instead." % (deprecated, correct), level='WARN'
)
[docs] def is_for_loop(self):
return True
[docs] def as_list(self, indent=False, include_comment=True):
comments = self.comment.as_list() if include_comment else []
return ['FOR'] + self.vars + [self.flavor] + self.items + comments
def __iter__(self):
return iter(self.steps)
[docs] def is_set(self):
return True
[docs]class Step(object):
def __init__(self, content, comment=None):
self.assign = self._get_assign(content)
self.name = content.pop(0) if content else None
self.args = content
self.comment = Comment(comment)
def _get_assign(self, content):
assign = []
while content and is_var(content[0].rstrip('= ')):
assign.append(content.pop(0))
return assign
[docs] def is_for_loop(self):
return False
[docs] def is_set(self):
return True
[docs] def as_list(self, indent=False, include_comment=True):
kw = [self.name] if self.name is not None else []
comments = self.comment.as_list() if include_comment else []
data = self.assign + kw + self.args + comments
if indent:
data.insert(0, '')
return data
[docs]class OldStyleSettingAndVariableTableHeaderMatcher(object):
[docs] def match(self, header):
return all(value.lower() == 'value' for value in header[1:])
[docs]class OldStyleTestAndKeywordTableHeaderMatcher(object):
[docs] def match(self, header):
if header[1].lower() != 'action':
return False
for arg in header[2:]:
if not arg.lower().startswith('arg'):
return False
return True