Source code for robot.parsing.model

#  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 = []
[docs] def set_header(self, header): if self._header and header: self._validate_mode(self._header[0], header[0]) _Table.set_header(self, header)
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_comment(self): return False
[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_comment(self): return not (self.assign or self.name or self.args)
[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