# 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 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