Source code for robot.parsing.model.blocks

#  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 warnings
from abc import ABC
from contextlib import contextmanager
from pathlib import Path
from typing import cast, Iterator, Sequence, TextIO, Union

from robot.utils import file_writer, test_or_task

from .statements import (Break, Continue, ElseHeader, ElseIfHeader, End, ExceptHeader,
                         Error, FinallyHeader, ForHeader, IfHeader, KeywordCall,
                         KeywordName, Node, ReturnSetting, ReturnStatement,
                         SectionHeader, Statement, TemplateArguments, TestCaseName,
                         TryHeader, Var, WhileHeader)
from .visitor import ModelVisitor
from ..lexer import Token


Body = Sequence[Union[Statement, 'Block']]
Errors = Sequence[str]


[docs] class Container(Node, ABC): @property def lineno(self) -> int: statement = FirstStatementFinder.find_from(self) return statement.lineno if statement else -1 @property def col_offset(self) -> int: statement = FirstStatementFinder.find_from(self) return statement.col_offset if statement else -1 @property def end_lineno(self) -> int: statement = LastStatementFinder.find_from(self) return statement.end_lineno if statement else -1 @property def end_col_offset(self) -> int: statement = LastStatementFinder.find_from(self) return statement.end_col_offset if statement else -1
[docs] def validate_model(self): ModelValidator().visit(self)
[docs] def validate(self, ctx: 'ValidationContext'): pass
[docs] class File(Container): _fields = ('sections',) _attributes = ('source', 'languages') + Container._attributes def __init__(self, sections: 'Sequence[Section]' = (), source: 'Path|None' = None, languages: Sequence[str] = ()): super().__init__() self.sections = list(sections) self.source = source self.languages = list(languages)
[docs] def save(self, output: 'Path|str|TextIO|None' = None): """Save model to the given ``output`` or to the original source file. The ``output`` can be a path to a file or an already opened file object. If ``output`` is not given, the original source file will be overwritten. """ output = output or self.source if output is None: raise TypeError('Saving model requires explicit output ' 'when original source is not path.') ModelWriter(output).write(self)
[docs] class Block(Container, ABC): _fields = ('header', 'body') def __init__(self, header: 'Statement|None', body: Body = (), errors: Errors = ()): self.header = header self.body = list(body) self.errors = tuple(errors) def _body_is_empty(self): # This works with tests, keywords, and blocks inside them, not with sections. valid = (KeywordCall, TemplateArguments, Var, Continue, Break, ReturnSetting, ReturnStatement, NestedBlock, Error) return not any(isinstance(node, valid) for node in self.body)
[docs] class Section(Block): header: 'SectionHeader|None'
[docs] class SettingSection(Section): header: SectionHeader
[docs] class VariableSection(Section): header: SectionHeader
# TODO: should there be a separate TaskSection?
[docs] class TestCaseSection(Section): header: SectionHeader @property def tasks(self) -> bool: return self.header.type == Token.TASK_HEADER
[docs] class KeywordSection(Section): header: SectionHeader
[docs] class CommentSection(Section): header: 'SectionHeader|None'
[docs] class ImplicitCommentSection(CommentSection): header: None def __init__(self, header: 'Statement|None' = None, body: Body = (), errors: Errors = ()): body = ([header] if header is not None else []) + list(body) super().__init__(None, body, errors)
[docs] class InvalidSection(Section): pass
[docs] class TestCase(Block): header: TestCaseName @property def name(self) -> str: return self.header.name
[docs] def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += (test_or_task('{Test} cannot be empty.', ctx.tasks),)
[docs] class Keyword(Block): header: KeywordName @property def name(self) -> str: return self.header.name
[docs] def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ("User keyword cannot be empty.",)
[docs] class NestedBlock(Block): _fields = ('header', 'body', 'end') def __init__(self, header: Statement, body: Body = (), end: 'End|None' = None, errors: Errors = ()): super().__init__(header, body, errors) self.end = end
[docs] class If(NestedBlock): """Represents IF structures in the model. Used with IF, Inline IF, ELSE IF and ELSE nodes. The :attr:`type` attribute specifies the type. """ _fields = ('header', 'body', 'orelse', 'end') header: 'IfHeader|ElseIfHeader|ElseHeader' def __init__(self, header: Statement, body: Body = (), orelse: 'If|None' = None, end: 'End|None' = None, errors: Errors = ()): super().__init__(header, body, end, errors) self.orelse = orelse @property def type(self) -> str: return self.header.type @property def condition(self) -> 'str|None': return self.header.condition @property def assign(self) -> 'tuple[str, ...]': return self.header.assign
[docs] def validate(self, ctx: 'ValidationContext'): self._validate_body() if self.type == Token.IF: self._validate_structure() self._validate_end() if self.type == Token.INLINE_IF: self._validate_structure() self._validate_inline_if()
def _validate_body(self): if self._body_is_empty(): type = self.type if self.type != Token.INLINE_IF else 'IF' self.errors += (f'{type} branch cannot be empty.',) def _validate_structure(self): orelse = self.orelse else_seen = False while orelse: if else_seen: if orelse.type == Token.ELSE: error = 'Only one ELSE allowed.' else: error = 'ELSE IF not allowed after ELSE.' if error not in self.errors: self.errors += (error,) else_seen = else_seen or orelse.type == Token.ELSE orelse = orelse.orelse def _validate_end(self): if not self.end: self.errors += ('IF must have closing END.',) def _validate_inline_if(self): branch = self assign = branch.assign while branch: if branch.body: item = cast(Statement, branch.body[0]) if assign and item.type != Token.KEYWORD: self.errors += ('Inline IF with assignment can only contain ' 'keyword calls.',) if getattr(item, 'assign', None): self.errors += ('Inline IF branches cannot contain assignments.',) if item.type == Token.INLINE_IF: self.errors += ('Inline IF cannot be nested.',) branch = branch.orelse
[docs] class For(NestedBlock): header: ForHeader @property def assign(self) -> 'tuple[str, ...]': return self.header.assign @property def variables(self) -> 'tuple[str, ...]': # TODO: Remove in RF 8.0. warnings.warn("'For.variables' is deprecated and will be removed in " "Robot Framework 8.0. Use 'For.assign' instead.") return self.assign @property def values(self) -> 'tuple[str, ...]': return self.header.values @property def flavor(self) -> 'str|None': return self.header.flavor @property def start(self) -> 'str|None': return self.header.start @property def mode(self) -> 'str|None': return self.header.mode @property def fill(self) -> 'str|None': return self.header.fill
[docs] def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ('FOR loop cannot be empty.',) if not self.end: self.errors += ('FOR loop must have closing END.',)
[docs] class Try(NestedBlock): _fields = ('header', 'body', 'next', 'end') header: 'TryHeader|ExceptHeader|ElseHeader|FinallyHeader' def __init__(self, header: Statement, body: Body = (), next: 'Try|None' = None, end: 'End|None' = None, errors: Errors = ()): super().__init__(header, body, end, errors) self.next = next @property def type(self) -> str: return self.header.type @property def patterns(self) -> 'tuple[str, ...]': return getattr(self.header, 'patterns', ()) @property def pattern_type(self) -> 'str|None': return getattr(self.header, 'pattern_type', None) @property def assign(self) -> 'str|None': return getattr(self.header, 'assign', None) @property def variable(self) -> 'str|None': # TODO: Remove in RF 8.0. warnings.warn("'Try.variable' is deprecated and will be removed in " "Robot Framework 8.0. Use 'Try.assign' instead.") return self.assign
[docs] def validate(self, ctx: 'ValidationContext'): self._validate_body() if self.type == Token.TRY: self._validate_structure() self._validate_end()
def _validate_body(self): if self._body_is_empty(): self.errors += (f'{self.type} branch cannot be empty.',) def _validate_structure(self): else_count = 0 finally_count = 0 except_count = 0 empty_except_count = 0 branch = self.next while branch: if branch.type == Token.EXCEPT: if else_count: self.errors += ('EXCEPT not allowed after ELSE.',) if finally_count: self.errors += ('EXCEPT not allowed after FINALLY.',) if branch.patterns and empty_except_count: self.errors += ('EXCEPT without patterns must be last.',) if not branch.patterns: empty_except_count += 1 except_count += 1 if branch.type == Token.ELSE: if finally_count: self.errors += ('ELSE not allowed after FINALLY.',) else_count += 1 if branch.type == Token.FINALLY: finally_count += 1 branch = branch.next if finally_count > 1: self.errors += ('Only one FINALLY allowed.',) if else_count > 1: self.errors += ('Only one ELSE allowed.',) if empty_except_count > 1: self.errors += ('Only one EXCEPT without patterns allowed.',) if not (except_count or finally_count): self.errors += ('TRY structure must have EXCEPT or FINALLY branch.',) def _validate_end(self): if not self.end: self.errors += ('TRY must have closing END.',)
[docs] class While(NestedBlock): header: WhileHeader @property def condition(self) -> str: return self.header.condition @property def limit(self) -> 'str|None': return self.header.limit @property def on_limit(self) -> 'str|None': return self.header.on_limit @property def on_limit_message(self) -> 'str|None': return self.header.on_limit_message
[docs] def validate(self, ctx: 'ValidationContext'): if self._body_is_empty(): self.errors += ('WHILE loop cannot be empty.',) if not self.end: self.errors += ('WHILE loop must have closing END.',)
[docs] class ModelWriter(ModelVisitor): def __init__(self, output: 'Path|str|TextIO'): if isinstance(output, (Path, str)): self.writer = file_writer(output) self.close_writer = True else: self.writer = output self.close_writer = False
[docs] def write(self, model: Node): try: self.visit(model) finally: if self.close_writer: self.writer.close()
[docs] def visit_Statement(self, statement: Statement): for token in statement: self.writer.write(token.value)
[docs] class ModelValidator(ModelVisitor): def __init__(self): self.ctx = ValidationContext()
[docs] def visit_Block(self, node: Block): with self.ctx.block(node): node.validate(self.ctx) super().generic_visit(node)
[docs] def visit_Statement(self, node: Statement): node.validate(self.ctx)
[docs] class ValidationContext: def __init__(self): self.blocks = []
[docs] @contextmanager def block(self, node: Block) -> Iterator[None]: self.blocks.append(node) try: yield finally: self.blocks.pop()
@property def parent_block(self) -> 'Block|None': return self.blocks[-1] if self.blocks else None @property def tasks(self) -> bool: for parent in self.blocks: if isinstance(parent, TestCaseSection): return parent.tasks return False @property def in_keyword(self) -> bool: return any(isinstance(b, Keyword) for b in self.blocks) @property def in_loop(self) -> bool: return any(isinstance(b, (For, While)) for b in self.blocks) @property def in_finally(self) -> bool: parent = self.parent_block return isinstance(parent, Try) and parent.header.type == Token.FINALLY
[docs] class FirstStatementFinder(ModelVisitor): def __init__(self): self.statement: 'Statement|None' = None
[docs] @classmethod def find_from(cls, model: Node) -> 'Statement|None': finder = cls() finder.visit(model) return finder.statement
[docs] def visit_Statement(self, statement: Statement): if self.statement is None: self.statement = statement
[docs] def generic_visit(self, node: Node): if self.statement is None: super().generic_visit(node)
[docs] class LastStatementFinder(ModelVisitor): def __init__(self): self.statement: 'Statement|None' = None
[docs] @classmethod def find_from(cls, model: Node) -> 'Statement|None': finder = cls() finder.visit(model) return finder.statement
[docs] def visit_Statement(self, statement: Statement): self.statement = statement