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 ..lexer import Token
from .statements import (
    Break, Continue, ElseHeader, ElseIfHeader, End, Error, ExceptHeader, FinallyHeader,
    ForHeader, GroupHeader, IfHeader, KeywordCall, KeywordName, Node, ReturnSetting,
    ReturnStatement, SectionHeader, Statement, TemplateArguments, TestCaseName,
    TryHeader, Var, WhileHeader
)
from .visitor import ModelVisitor

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, Group, 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() TemplatesNotAllowed("TRY").check(self)
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.",) TemplatesNotAllowed("WHILE").check(self)
[docs] class Group(NestedBlock): header: GroupHeader @property def name(self) -> str: return self.header.name
[docs] def validate(self, ctx: "ValidationContext"): if self._body_is_empty(): self.errors += ("GROUP cannot be empty.",) if not self.end: self.errors += ("GROUP 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
[docs] class TemplatesNotAllowed(ModelVisitor): def __init__(self, kind: str): self.kind = kind self.found = False
[docs] def check(self, model: Node): self.found = False self.visit(model) if self.found: model.errors += (f"{self.kind} does not support templates.",)
[docs] def visit_TemplateArguments(self, node: None): self.found = True