From 29dc0950a6302c8736852d6afe67ea2464ea10e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Lepp=C3=A4nen?= Date: Thu, 17 Oct 2024 14:34:31 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Enhance=20reporting=20?= =?UTF-8?q?errors=20and=20warnings=20in=20parsing=20model=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change introduces a new type, "InvalidTokenError," to hold and represent invalid tokens while parsing source files. This improves the separation of concerns and allows better control and isolation of how invalid tokens should be handled. --- src/robot/parsing/__init__.py | 21 ++- src/robot/parsing/lexer/__init__.py | 4 +- src/robot/parsing/lexer/context.py | 61 ++++--- src/robot/parsing/lexer/settings.py | 12 +- src/robot/parsing/lexer/statementlexers.py | 15 +- src/robot/parsing/lexer/tokens.py | 63 +++++++- src/robot/parsing/model/statements.py | 16 +- src/robot/running/builder/transformers.py | 20 +-- utest/parsing/test_lexer.py | 149 +++++++++--------- utest/parsing/test_model.py | 91 ++++++++--- utest/parsing/test_statements.py | 17 +- .../test_statements_in_invalid_position.py | 17 +- utest/parsing/test_tokens.py | 26 ++- 13 files changed, 344 insertions(+), 168 deletions(-) diff --git a/src/robot/parsing/__init__.py b/src/robot/parsing/__init__.py index 3ad2107bc29..2d1f7aa0964 100644 --- a/src/robot/parsing/__init__.py +++ b/src/robot/parsing/__init__.py @@ -21,8 +21,21 @@ :mod:`robot.api.parsing`. """ -from .lexer import get_tokens, get_resource_tokens, get_init_tokens, Token +from .lexer import ( + ErrorCode, + ErrorKind, + InvalidTokenError, + Token, + get_init_tokens, + get_resource_tokens, + get_tokens, +) from .model import File, ModelTransformer, ModelVisitor -from .parser import get_model, get_resource_model, get_init_model -from .suitestructure import (SuiteFile, SuiteDirectory, SuiteStructure, - SuiteStructureBuilder, SuiteStructureVisitor) +from .parser import get_init_model, get_model, get_resource_model +from .suitestructure import ( + SuiteDirectory, + SuiteFile, + SuiteStructure, + SuiteStructureBuilder, + SuiteStructureVisitor, +) diff --git a/src/robot/parsing/lexer/__init__.py b/src/robot/parsing/lexer/__init__.py index 26196da4535..d927c2da425 100644 --- a/src/robot/parsing/lexer/__init__.py +++ b/src/robot/parsing/lexer/__init__.py @@ -13,5 +13,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .lexer import get_tokens, get_resource_tokens, get_init_tokens -from .tokens import StatementTokens, Token +from .lexer import get_init_tokens, get_resource_tokens, get_tokens +from .tokens import ErrorCode, ErrorKind, InvalidTokenError, StatementTokens, Token diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index df0df7f5087..020dab6bb8b 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -13,12 +13,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.conf import Languages, LanguageLike, LanguagesLike +from robot.conf import LanguageLike, Languages, LanguagesLike from robot.utils import normalize_whitespace -from .settings import (InitFileSettings, FileSettings, Settings, SuiteFileSettings, - ResourceFileSettings, TestCaseSettings, KeywordSettings) -from .tokens import StatementTokens, Token +from .settings import ( + FileSettings, + InitFileSettings, + KeywordSettings, + ResourceFileSettings, + Settings, + SuiteFileSettings, + TestCaseSettings, +) +from .tokens import ErrorCode, ErrorKind, InvalidTokenError, StatementTokens, Token class LexingContext: @@ -71,7 +78,7 @@ def lex_invalid_section(self, statement: StatementTokens): for token in statement[1:]: token.type = Token.COMMENT - def _get_invalid_section_error(self, header: str) -> str: + def _get_invalid_section_error(self, header: str) -> InvalidTokenError: raise NotImplementedError def _handles_section(self, statement: StatementTokens, header: str) -> bool: @@ -82,10 +89,10 @@ def _handles_section(self, statement: StatementTokens, header: str) -> bool: if self.languages.headers.get(normalized) == header: return True if normalized == header[:-1]: - statement[0].error = ( - f"Singular section headers like '{marker}' are deprecated. " - f"Use plural format like '*** {header} ***' instead." - ) + statement[0].error = InvalidTokenError( + kind=ErrorKind.WARNING, + code=ErrorCode.SINGULAR_HEADER_DEPRECATED, + message=f"Singular section headers like '{marker}' are deprecated. Use plural format like '*** {header} ***' instead.") return True return False @@ -105,32 +112,44 @@ def test_case_section(self, statement: StatementTokens) -> bool: def task_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Tasks') - def _get_invalid_section_error(self, header: str) -> str: - return (f"Unrecognized section header '{header}'. Valid sections: " - f"'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' " - f"and 'Comments'.") + def _get_invalid_section_error(self, header: str) -> InvalidTokenError: + return InvalidTokenError( + kind=ErrorKind.ERROR, + code=ErrorCode.INVALID_SECTION_HEADER, + message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' and 'Comments'.") class ResourceFileContext(FileContext): settings: ResourceFileSettings - def _get_invalid_section_error(self, header: str) -> str: + def _get_invalid_section_error(self, header: str) -> InvalidTokenError: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - return f"Resource file with '{name}' section is invalid." - return (f"Unrecognized section header '{header}'. Valid sections: " - f"'Settings', 'Variables', 'Keywords' and 'Comments'.") + return InvalidTokenError( + kind=ErrorKind.ERROR, + code=ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, + message=f"Resource file with '{name}' section is invalid.", is_fatal=True) + return InvalidTokenError( + kind=ErrorKind.ERROR, + code=ErrorCode.INVALID_SECTION_HEADER, + message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'.", + is_fatal=True) class InitFileContext(FileContext): settings: InitFileSettings - def _get_invalid_section_error(self, header: str) -> str: + def _get_invalid_section_error(self, header: str) -> InvalidTokenError: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - return f"'{name}' section is not allowed in suite initialization file." - return (f"Unrecognized section header '{header}'. Valid sections: " - f"'Settings', 'Variables', 'Keywords' and 'Comments'.") + return InvalidTokenError( + kind=ErrorKind.ERROR, + code=ErrorCode.INVALID_SECTION_IN_INIT_FILE, + message=f"'{name}' section is not allowed in suite initialization file.") + return InvalidTokenError( + kind=ErrorKind.ERROR, + code=ErrorCode.INVALID_SECTION_HEADER, + message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'.") class TestCaseContext(LexingContext): diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index e5d7955927e..e0f6d08293b 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -16,9 +16,9 @@ from abc import ABC, abstractmethod from robot.conf import Languages -from robot.utils import normalize, normalize_whitespace, RecommendationFinder +from robot.utils import RecommendationFinder, normalize, normalize_whitespace -from .tokens import StatementTokens, Token +from .tokens import ErrorCode, ErrorKind, InvalidTokenError, StatementTokens, Token class Settings(ABC): @@ -68,7 +68,7 @@ def lex(self, statement: StatementTokens): try: self._validate(orig, name, statement) except ValueError as err: - self._lex_error(statement, err.args[0]) + self._lex_error(statement, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, err.args[0])) else: self._lex_setting(statement, name) @@ -106,7 +106,7 @@ def _is_valid_somewhere(self, name: str, classes: 'list[type[Settings]]') -> boo def _not_valid_here(self, name: str) -> str: raise NotImplementedError - def _lex_error(self, statement: StatementTokens, error: str): + def _lex_error(self, statement: StatementTokens, error: InvalidTokenError): statement[0].set_error(error) for token in statement[1:]: token.type = Token.COMMENT @@ -122,8 +122,8 @@ def _lex_setting(self, statement: StatementTokens, name: str): else: self._lex_arguments(values) if name == 'Return': - statement[0].error = ("The '[Return]' setting is deprecated. " - "Use the 'RETURN' statement instead.") + statement[0].error = InvalidTokenError(ErrorKind.WARNING, ErrorCode.RETURN_SETTING_DEPRECATED, + "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead.") def _lex_name_and_arguments(self, tokens: StatementTokens): if tokens: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index d1f5cf64f98..b19ee5b1f94 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -19,8 +19,8 @@ from robot.utils import normalize_whitespace from robot.variables import is_assign -from .context import FileContext, LexingContext, KeywordContext, TestCaseContext -from .tokens import StatementTokens, Token +from .context import FileContext, KeywordContext, LexingContext, TestCaseContext +from .tokens import ErrorCode, ErrorKind, InvalidTokenError, StatementTokens, Token class Lexer(ABC): @@ -140,10 +140,9 @@ def input(self, statement: StatementTokens): try: self.ctx.add_language(lang) except DataError: - statement[0].set_error( - f"Invalid language configuration: " - f"Language '{lang}' not found nor importable as a language module." - ) + statement[0].set_error(InvalidTokenError(ErrorKind.ERROR, + ErrorCode.INVALID_LANGUAGE_CONFIGURATION, + f"Invalid language configuration: Language '{lang}' not found nor importable as a language module.")) else: statement[0].type = Token.CONFIG @@ -387,6 +386,8 @@ def handles(self, statement: StatementTokens) -> bool: def lex(self): token = self.statement[0] - token.set_error(f'{token.value} is not allowed in this context.') + token.set_error(InvalidTokenError(ErrorKind.ERROR, + ErrorCode.SYNTAX_ERROR, + f'{token.value} is not allowed in this context.')) for t in self.statement[1:]: t.type = Token.ARGUMENT diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index f38dfee8893..2148b10803c 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -14,15 +14,61 @@ # limitations under the License. from collections.abc import Iterator -from typing import cast, List +from dataclasses import dataclass +from enum import Enum, auto +from typing import List, cast +from robot.errors import DataError +from robot.output import LOGGER from robot.variables import VariableMatches - # Type alias to ease typing elsewhere StatementTokens = List['Token'] +class ErrorCode(Enum): + INVALID_LANGUAGE_CONFIGURATION = auto() + INVALID_SECTION_HEADER = auto() + INVALID_SECTION_IN_INIT_FILE = auto() + INVALID_SECTION_IN_RESOURCE_FILE = auto() + + RETURN_SETTING_DEPRECATED = auto() + SETTINGS_VALIDATION_ERROR = auto() + SINGULAR_HEADER_DEPRECATED = auto() + SYNTAX_ERROR = auto() + + +class ErrorKind(Enum): + WARNING = 'WARNING' + ERROR = 'ERROR' + + +@dataclass(frozen=True) +class InvalidTokenError: + kind: ErrorKind + code: ErrorCode + message: str | None = None + is_fatal: bool = False + + def __str__(self) -> str: + return f"{self.message or ''}" + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.kind.value}, {self.code.name}, {self.message!r})' + + def __eq__(self, value: object) -> bool: + if not isinstance(value, InvalidTokenError): + return False + return (self.kind == value.kind and self.code == value.code and self.message == value.message) + + @property + def is_warning(self) -> bool: + return self.kind == ErrorKind.WARNING + + @property + def should_throw(self) -> bool: + return self.kind == ErrorKind.ERROR and self.is_fatal + class Token: """Token representing piece of Robot Framework data. @@ -162,7 +208,7 @@ class Token: '_add_eos_before', '_add_eos_after'] def __init__(self, type: 'str|None' = None, value: 'str|None' = None, - lineno: int = -1, col_offset: int = -1, error: 'str|None' = None): + lineno: int = -1, col_offset: int = -1, error: 'InvalidTokenError|None' = None): self.type = type if value is None: value = { @@ -188,7 +234,7 @@ def end_col_offset(self) -> int: return -1 return self.col_offset + len(self.value) - def set_error(self, error: str): + def set_error(self, error: InvalidTokenError) -> None: self.type = Token.ERROR self.error = error @@ -240,6 +286,15 @@ def __eq__(self, other) -> bool: and self.col_offset == other.col_offset and self.error == other.error) + def dump_error_or_raise(self, source: str) -> None: + if self.error is None: + return + message = f"Error in file '{source}' on line {self.lineno}: {self.error}" + + if self.type == Token.INVALID_HEADER: + raise DataError(message) + + LOGGER.write(message, level='WARN' if self.error.is_warning else 'ERROR') class EOS(Token): """Token representing end of a statement.""" diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index 2904920a4a6..fc845ba9939 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -18,13 +18,18 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Iterator, Sequence -from typing import cast, ClassVar, Literal, overload, TYPE_CHECKING, Type, TypeVar +from typing import TYPE_CHECKING, ClassVar, Literal, Type, TypeVar, cast, overload from robot.conf import Language +from robot.parsing.lexer.tokens import InvalidTokenError from robot.running.arguments import UserKeywordArgumentParser from robot.utils import normalize_whitespace, seq2str, split_from_equals, test_or_task -from robot.variables import (contains_variable, is_scalar_assign, is_dict_variable, - search_variable) +from robot.variables import ( + contains_variable, + is_dict_variable, + is_scalar_assign, + search_variable, +) from ..lexer import Token @@ -1353,7 +1358,7 @@ class Error(Statement): _errors: 'tuple[str, ...]' = () @classmethod - def from_params(cls, error: str, value: str = '', indent: str = FOUR_SPACES, + def from_params(cls, error: InvalidTokenError, value: str = '', indent: str = FOUR_SPACES, eol: str = EOL) -> 'Error': return cls([ Token(Token.SEPARATOR, indent), @@ -1373,13 +1378,12 @@ def errors(self) -> 'tuple[str, ...]': along with errors got from tokens. """ tokens = self.get_tokens(Token.ERROR) - return tuple(t.error or '' for t in tokens) + self._errors + return tuple(str(t.error.message) if t.error else "" for t in tokens) + self._errors @errors.setter def errors(self, errors: 'Sequence[str]'): self._errors = tuple(errors) - class EmptyLine(Statement): type = Token.EOL diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index c7734b2bd61..0125cf9b332 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -19,7 +19,7 @@ from robot.utils import NormalizedDict from robot.variables import VariableMatches -from ..model import For, If, IfBranch, TestSuite, TestCase, Try, TryBranch, While +from ..model import For, If, IfBranch, TestCase, TestSuite, Try, TryBranch, While from ..resourcemodel import ResourceFile, UserKeyword from .settings import FileSettings @@ -509,20 +509,22 @@ def visit_ReturnSetting(self, node): def visit_SectionHeader(self, node): token = node.get_token(*Token.HEADER_TOKENS) - if not token.error: + if token is None or token.error is None: return - if token.type == Token.INVALID_HEADER: - self.report_error(token, throw=self.raise_on_invalid_header) - else: - # Errors, other than totally invalid headers, can occur only with - # deprecated singular headers, and we want to report them as warnings. - # A more generic solution for separating errors and warnings would be good. - self.report_error(token, warn=True) + self.report_invalid_token_or_raise(token) def visit_Error(self, node): for token in node.get_tokens(Token.ERROR): self.report_error(token) + def report_invalid_token_or_raise(self, token: Token): + if token.error is None: # assert for the type checker that error is not None + return + message = f"Error in file '{self.source}' on line {token.lineno}: {token.error}" + if token.type == Token.INVALID_HEADER and token.error.should_throw: + raise DataError(message) + LOGGER.write(message, level='WARN' if token.error.is_warning else 'ERROR') + def report_error(self, source, error=None, warn=False, throw=False): if not error: if isinstance(source, Token): diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 9767f239a74..7062e0e97eb 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -1,13 +1,13 @@ import os -import unittest import tempfile +import unittest from io import StringIO from pathlib import Path from robot.conf import Language, Languages +from robot.parsing import Token, get_init_tokens, get_resource_tokens, get_tokens +from robot.parsing.lexer.tokens import ErrorCode, ErrorKind, InvalidTokenError from robot.utils.asserts import assert_equal -from robot.parsing import get_tokens, get_init_tokens, get_resource_tokens, Token - T = Token @@ -122,13 +122,13 @@ def test_suite_settings_not_allowed_in_init_file(self): (T.SETTING_HEADER, '*** Settings ***', 1, 0), (T.EOS, '', 1, 16), (T.ERROR, 'Test Template', 2, 0, - "Setting 'Test Template' is not allowed in suite initialization file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Template' is not allowed in suite initialization file.")), (T.EOS, '', 2, 13), (T.TEST_TAGS, 'Test Tags', 3, 0), (T.ARGUMENT, 'Allowed in both', 3, 18), (T.EOS, '', 3, 33), (T.ERROR, 'Default Tags', 4, 0, - "Setting 'Default Tags' is not allowed in suite initialization file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Default Tags' is not allowed in suite initialization file.")), (T.EOS, '', 4, 12) ] assert_tokens(data, expected, get_init_tokens, data_only=True) @@ -154,40 +154,40 @@ def test_suite_settings_not_allowed_in_resource_file(self): (T.SETTING_HEADER, '*** Settings ***', 1, 0), (T.EOS, '', 1, 16), (T.ERROR, 'Metadata', 2, 0, - "Setting 'Metadata' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Metadata' is not allowed in resource file.")), (T.EOS, '', 2, 8), (T.ERROR, 'Suite Setup', 3, 0, - "Setting 'Suite Setup' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Suite Setup' is not allowed in resource file.")), (T.EOS, '', 3, 11), (T.ERROR, 'suite teardown', 4, 0, - "Setting 'suite teardown' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'suite teardown' is not allowed in resource file.")), (T.EOS, '', 4, 14), (T.ERROR, 'Test Setup', 5, 0, - "Setting 'Test Setup' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Setup' is not allowed in resource file.")), (T.EOS, '', 5, 10), (T.ERROR, 'TEST TEARDOWN', 6, 0, - "Setting 'TEST TEARDOWN' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'TEST TEARDOWN' is not allowed in resource file.")), (T.EOS, '', 6, 13), (T.ERROR, 'Test Template', 7, 0, - "Setting 'Test Template' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Template' is not allowed in resource file.")), (T.EOS, '', 7, 13), (T.ERROR, 'Test Timeout', 8, 0, - "Setting 'Test Timeout' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Timeout' is not allowed in resource file.")), (T.EOS, '', 8, 12), (T.ERROR, 'Test Tags', 9, 0, - "Setting 'Test Tags' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Tags' is not allowed in resource file.")), (T.EOS, '', 9, 9), (T.ERROR, 'Default Tags', 10, 0, - "Setting 'Default Tags' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Default Tags' is not allowed in resource file.")), (T.EOS, '', 10, 12), (T.ERROR, 'Task Tags', 11, 0, - "Setting 'Task Tags' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Task Tags' is not allowed in resource file.")), (T.EOS, '', 11, 9), (T.DOCUMENTATION, 'Documentation', 12, 0), (T.ARGUMENT, 'Valid in all data files.', 12, 18), (T.EOS, '', 12, 42), (T.ERROR, "Name", 13, 0, - "Setting 'Name' is not allowed in resource file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Name' is not allowed in resource file.")), (T.EOS, '', 13, 4) ] assert_tokens(data, expected, get_resource_tokens, data_only=True) @@ -277,15 +277,15 @@ def test_invalid_settings(self): expected = [ (T.SETTING_HEADER, '*** Settings ***', 1, 0), (T.EOS, '', 1, 16), - (T.ERROR, 'Invalid', 2, 0, "Non-existing setting 'Invalid'."), + (T.ERROR, 'Invalid', 2, 0, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Non-existing setting 'Invalid'.")), (T.EOS, '', 2, 7), (T.LIBRARY, 'Library', 3, 0), (T.NAME, 'Valid', 3, 14), (T.EOS, '', 3, 19), - (T.ERROR, 'Oops, I', 4, 0, "Non-existing setting 'Oops, I'."), + (T.ERROR, 'Oops, I', 4, 0, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Non-existing setting 'Oops, I'.")), (T.EOS, '', 4, 7), - (T.ERROR, 'Libra ry', 5, 0, "Non-existing setting 'Libra ry'. " - "Did you mean:\n Library"), + (T.ERROR, 'Libra ry', 5, 0, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Non-existing setting 'Libra ry'. " + "Did you mean:\n Library")), (T.EOS, '', 5, 8) ] assert_tokens(data, expected, get_tokens, data_only=True) @@ -305,16 +305,16 @@ def test_too_many_values_for_single_value_settings(self): (T.SETTING_HEADER, '*** Settings ***', 1, 0), (T.EOS, '', 1, 16), (T.ERROR, 'Resource', 2, 0, - "Setting 'Resource' accepts only one value, got 3."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Resource' accepts only one value, got 3.")), (T.EOS, '', 2, 8), (T.ERROR, 'Test Timeout', 3, 0, - "Setting 'Test Timeout' accepts only one value, got 2."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Timeout' accepts only one value, got 2.")), (T.EOS, '', 3, 12), (T.ERROR, 'Test Template', 4, 0, - "Setting 'Test Template' accepts only one value, got 5."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Template' accepts only one value, got 5.")), (T.EOS, '', 4, 13), (T.ERROR, 'NaMe', 5, 0, - "Setting 'NaMe' accepts only one value, got 5."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'NaMe' accepts only one value, got 5.")), (T.EOS, '', 5, 4), ] assert_tokens(data, expected, data_only=True) @@ -351,61 +351,61 @@ def test_setting_too_many_times(self): (T.ARGUMENT, 'Used', 2, 18), (T.EOS, '', 2, 22), (T.ERROR, 'Documentation', 3, 0, - "Setting 'Documentation' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Documentation' is allowed only once. Only the first value is used.")), (T.EOS, '', 3, 13), (T.SUITE_SETUP, 'Suite Setup', 4, 0), (T.NAME, 'Used', 4, 18), (T.EOS, '', 4, 22), (T.ERROR, 'Suite Setup', 5, 0, - "Setting 'Suite Setup' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Suite Setup' is allowed only once. Only the first value is used.")), (T.EOS, '', 5, 11), (T.SUITE_TEARDOWN, 'Suite Teardown', 6, 0), (T.NAME, 'Used', 6, 18), (T.EOS, '', 6, 22), (T.ERROR, 'Suite Teardown', 7, 0, - "Setting 'Suite Teardown' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Suite Teardown' is allowed only once. Only the first value is used.")), (T.EOS, '', 7, 14), (T.TEST_SETUP, 'Test Setup', 8, 0), (T.NAME, 'Used', 8, 18), (T.EOS, '', 8, 22), (T.ERROR, 'Test Setup', 9, 0, - "Setting 'Test Setup' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Setup' is allowed only once. Only the first value is used.")), (T.EOS, '', 9, 10), (T.TEST_TEARDOWN, 'Test Teardown', 10, 0), (T.NAME, 'Used', 10, 18), (T.EOS, '', 10, 22), (T.ERROR, 'Test Teardown', 11, 0, - "Setting 'Test Teardown' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Teardown' is allowed only once. Only the first value is used.")), (T.EOS, '', 11, 13), (T.TEST_TEMPLATE, 'Test Template', 12, 0), (T.NAME, 'Used', 12, 18), (T.EOS, '', 12, 22), (T.ERROR, 'Test Template', 13, 0, - "Setting 'Test Template' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Template' is allowed only once. Only the first value is used.")), (T.EOS, '', 13, 13), (T.TEST_TIMEOUT, 'Test Timeout', 14, 0), (T.ARGUMENT, 'Used', 14, 18), (T.EOS, '', 14, 22), (T.ERROR, 'Test Timeout', 15, 0, - "Setting 'Test Timeout' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Test Timeout' is allowed only once. Only the first value is used.")), (T.EOS, '', 15, 12), (T.TEST_TAGS, 'Test Tags', 16, 0), (T.ARGUMENT, 'Used', 16, 18), (T.EOS, '', 16, 22), (T.ERROR, 'Test Tags', 17, 0, - "Setting 'Test Tags' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR,"Setting 'Test Tags' is allowed only once. Only the first value is used.")), (T.EOS, '', 17, 9), (T.DEFAULT_TAGS, 'Default Tags', 18, 0), (T.ARGUMENT, 'Used', 18, 18), (T.EOS, '', 18, 22), (T.ERROR, 'Default Tags', 19, 0, - "Setting 'Default Tags' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Default Tags' is allowed only once. Only the first value is used.")), (T.EOS, '', 19, 12), ("SUITE NAME", 'Name', 20, 0), (T.ARGUMENT, 'Used', 20, 18), (T.EOS, '', 20, 22), (T.ERROR, 'Name', 21, 0, - "Setting 'Name' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Name' is allowed only once. Only the first value is used.")), (T.EOS, '', 21, 4) ] assert_tokens(data, expected, data_only=True) @@ -500,7 +500,7 @@ def test_keyword_settings(self): (T.ARGUMENT, '${TIMEOUT}', 9, 23), (T.EOS, '', 9, 33), (T.RETURN, '[Return]', 10, 4, - "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead."), + InvalidTokenError(ErrorKind.WARNING, ErrorCode.RETURN_SETTING_DEPRECATED, "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead.")), (T.ARGUMENT, 'Value', 10, 23), (T.EOS, '', 10, 28) ] @@ -521,10 +521,10 @@ def test_too_many_values_for_single_value_test_settings(self): (T.TESTCASE_NAME, 'Name', 2, 0), (T.EOS, '', 2, 4), (T.ERROR, '[Timeout]', 3, 4, - "Setting 'Timeout' accepts only one value, got 4."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Timeout' accepts only one value, got 4.")), (T.EOS, '', 3, 13), (T.ERROR, '[Template]', 4, 4, - "Setting 'Template' accepts only one value, got 3."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Template' accepts only one value, got 3.")), (T.EOS, '', 4, 14) ] assert_tokens(data, expected, data_only=True) @@ -542,7 +542,7 @@ def test_too_many_values_for_single_value_keyword_settings(self): (T.KEYWORD_NAME, 'Name', 2, 0), (T.EOS, '', 2, 4), (T.ERROR, '[Timeout]', 3, 4, - "Setting 'Timeout' accepts only one value, got 4."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Timeout' accepts only one value, got 4.")), (T.EOS, '', 3, 13), ] assert_tokens(data, expected, data_only=True) @@ -574,37 +574,37 @@ def test_test_settings_too_many_times(self): (T.ARGUMENT, 'Used', 3, 23), (T.EOS, '', 3, 27), (T.ERROR, '[Documentation]', 4, 4, - "Setting 'Documentation' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Documentation' is allowed only once. Only the first value is used.")), (T.EOS, '', 4, 19), (T.TAGS, '[Tags]', 5, 4), (T.ARGUMENT, 'Used', 5, 23), (T.EOS, '', 5, 27), (T.ERROR, '[Tags]', 6, 4, - "Setting 'Tags' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Tags' is allowed only once. Only the first value is used.")), (T.EOS, '', 6, 10), (T.SETUP, '[Setup]', 7, 4), (T.NAME, 'Used', 7, 23), (T.EOS, '', 7, 27), (T.ERROR, '[Setup]', 8, 4, - "Setting 'Setup' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Setup' is allowed only once. Only the first value is used.")), (T.EOS, '', 8, 11), (T.TEARDOWN, '[Teardown]', 9, 4), (T.NAME, 'Used', 9, 23), (T.EOS, '', 9, 27), (T.ERROR, '[Teardown]', 10, 4, - "Setting 'Teardown' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Teardown' is allowed only once. Only the first value is used.")), (T.EOS, '', 10, 14), (T.TEMPLATE, '[Template]', 11, 4), (T.NAME, 'Used', 11, 23), (T.EOS, '', 11, 27), (T.ERROR, '[Template]', 12, 4, - "Setting 'Template' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Template' is allowed only once. Only the first value is used.")), (T.EOS, '', 12, 14), (T.TIMEOUT, '[Timeout]', 13, 4), (T.ARGUMENT, 'Used', 13, 23), (T.EOS, '', 13, 27), (T.ERROR, '[Timeout]', 14, 4, - "Setting 'Timeout' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Timeout' is allowed only once. Only the first value is used.")), (T.EOS, '', 14, 13) ] assert_tokens(data, expected, data_only=True) @@ -636,38 +636,38 @@ def test_keyword_settings_too_many_times(self): (T.ARGUMENT, 'Used', 3, 23), (T.EOS, '', 3, 27), (T.ERROR, '[Documentation]', 4, 4, - "Setting 'Documentation' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Documentation' is allowed only once. Only the first value is used.")), (T.EOS, '', 4, 19), (T.TAGS, '[Tags]', 5, 4), (T.ARGUMENT, 'Used', 5, 23), (T.EOS, '', 5, 27), (T.ERROR, '[Tags]', 6, 4, - "Setting 'Tags' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Tags' is allowed only once. Only the first value is used.")), (T.EOS, '', 6, 10), (T.ARGUMENTS, '[Arguments]', 7, 4), (T.ARGUMENT, 'Used', 7, 23), (T.EOS, '', 7, 27), (T.ERROR, '[Arguments]', 8, 4, - "Setting 'Arguments' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Arguments' is allowed only once. Only the first value is used.")), (T.EOS, '', 8, 15), (T.TEARDOWN, '[Teardown]', 9, 4), (T.NAME, 'Used', 9, 23), (T.EOS, '', 9, 27), (T.ERROR, '[Teardown]', 10, 4, - "Setting 'Teardown' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Teardown' is allowed only once. Only the first value is used.")), (T.EOS, '', 10, 14), (T.TIMEOUT, '[Timeout]', 11, 4), (T.ARGUMENT, 'Used', 11, 23), (T.EOS, '', 11, 27), (T.ERROR, '[Timeout]', 12, 4, - "Setting 'Timeout' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Timeout' is allowed only once. Only the first value is used.")), (T.EOS, '', 12, 13), (T.RETURN, '[Return]', 13, 4, - "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead."), + InvalidTokenError(ErrorKind.WARNING, ErrorCode.RETURN_SETTING_DEPRECATED, "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead.")), (T.ARGUMENT, 'Used', 13, 23), (T.EOS, '', 13, 27), (T.ERROR, '[Return]', 14, 4, - "Setting 'Return' is allowed only once. Only the first value is used."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Setting 'Return' is allowed only once. Only the first value is used.")), (T.EOS, '', 14, 12) ] assert_tokens(data, expected, data_only=True) @@ -728,38 +728,38 @@ def test_test_case_section(self): def test_case_section_causes_error_in_init_file(self): assert_tokens('*** Test Cases ***', [ (T.INVALID_HEADER, '*** Test Cases ***', 1, 0, - "'Test Cases' section is not allowed in suite initialization file."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_INIT_FILE, "'Test Cases' section is not allowed in suite initialization file.")), (T.EOS, '', 1, 18), ], get_init_tokens, data_only=True) def test_case_section_causes_fatal_error_in_resource_file(self): assert_tokens('*** Test Cases ***', [ (T.INVALID_HEADER, '*** Test Cases ***', 1, 0, - "Resource file with 'Test Cases' section is invalid."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, "Resource file with 'Test Cases' section is invalid.")), (T.EOS, '', 1, 18), ], get_resource_tokens, data_only=True) def test_invalid_section_in_test_case_file(self): assert_tokens('*** Invalid ***', [ (T.INVALID_HEADER, '*** Invalid ***', 1, 0, - "Unrecognized section header '*** Invalid ***'. Valid sections: " - "'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' and 'Comments'."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, "Unrecognized section header '*** Invalid ***'. Valid sections: " + "'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' and 'Comments'.")), (T.EOS, '', 1, 15), ], data_only=True) def test_invalid_section_in_init_file(self): assert_tokens('*** S e t t i n g s ***', [ (T.INVALID_HEADER, '*** S e t t i n g s ***', 1, 0, - "Unrecognized section header '*** S e t t i n g s ***'. Valid sections: " - "'Settings', 'Variables', 'Keywords' and 'Comments'."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, "Unrecognized section header '*** S e t t i n g s ***'. Valid sections: " + "'Settings', 'Variables', 'Keywords' and 'Comments'.")), (T.EOS, '', 1, 23), ], get_init_tokens, data_only=True) def test_invalid_section_in_resource_file(self): assert_tokens('*', [ (T.INVALID_HEADER, '*', 1, 0, - "Unrecognized section header '*'. Valid sections: " - "'Settings', 'Variables', 'Keywords' and 'Comments'."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, "Unrecognized section header '*'. Valid sections: " + "'Settings', 'Variables', 'Keywords' and 'Comments'.")), (T.EOS, '', 1, 1), ], get_resource_tokens, data_only=True) @@ -772,23 +772,27 @@ def test_singular_headers_are_deprecated(self): ''' expected = [ (T.SETTING_HEADER, '*** Setting ***', 1, 0, + InvalidTokenError(ErrorKind.WARNING, ErrorCode.SINGULAR_HEADER_DEPRECATED, "Singular section headers like '*** Setting ***' are deprecated. " - "Use plural format like '*** Settings ***' instead."), + "Use plural format like '*** Settings ***' instead.")), (T.EOL, '\n', 1, 15), (T.EOS, '', 1, 16), (T.VARIABLE_HEADER, '***variable***', 2, 0, + InvalidTokenError(ErrorKind.WARNING, ErrorCode.SINGULAR_HEADER_DEPRECATED, "Singular section headers like '***variable***' are deprecated. " - "Use plural format like '*** Variables ***' instead."), + "Use plural format like '*** Variables ***' instead.")), (T.EOL, '\n', 2, 14), (T.EOS, '', 2, 15), (T.KEYWORD_HEADER, '*Keyword', 3, 0, + InvalidTokenError(ErrorKind.WARNING, ErrorCode.SINGULAR_HEADER_DEPRECATED, "Singular section headers like '*Keyword' are deprecated. " - "Use plural format like '*** Keywords ***' instead."), + "Use plural format like '*** Keywords ***' instead.")), (T.EOL, '\n', 3, 8), (T.EOS, '', 3, 9), (T.COMMENT_HEADER, '*** Comment ***', 4, 0, + InvalidTokenError(ErrorKind.WARNING, ErrorCode.SINGULAR_HEADER_DEPRECATED, "Singular section headers like '*** Comment ***' are deprecated. " - "Use plural format like '*** Comments ***' instead."), + "Use plural format like '*** Comments ***' instead.")), (T.EOL, '\n', 4, 15), (T.EOS, '', 4, 16) ] @@ -797,8 +801,9 @@ def test_singular_headers_are_deprecated(self): assert_tokens(data, expected, get_resource_tokens) assert_tokens('*** Test Case ***', [ (T.TESTCASE_HEADER, '*** Test Case ***', 1, 0, + InvalidTokenError(ErrorKind.WARNING, ErrorCode.SINGULAR_HEADER_DEPRECATED, "Singular section headers like '*** Test Case ***' are deprecated. " - "Use plural format like '*** Test Cases ***' instead."), + "Use plural format like '*** Test Cases ***' instead.")), (T.EOL, '', 1, 17), (T.EOS, '', 1, 17), ]) @@ -1754,7 +1759,7 @@ def test_settings(self): (T.NAME, 'Your', 2, 57), (T.VARIABLE, '${Name}', 2, 61), (T.EOS, '', 2, 68), - (T.ERROR, '${invalid}', 3, 0, "Non-existing setting '${invalid}'."), + (T.ERROR, '${invalid}', 3, 0, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, "Non-existing setting '${invalid}'.")), (T.EOS, '', 3, 10)] assert_tokens(data, expected, get_tokens=get_tokens, data_only=True, tokenize_variables=True) @@ -1980,7 +1985,7 @@ def test_in_keyword(self): def test_in_test(self): data = ' RETURN' - expected = [(T.ERROR, 'RETURN', 3, 4, 'RETURN is not allowed in this context.'), + expected = [(T.ERROR, 'RETURN', 3, 4, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'RETURN is not allowed in this context.')), (T.EOS, '', 3, 10)] self._verify(data, expected, test=True) @@ -2039,13 +2044,13 @@ class TestContinue(unittest.TestCase): def test_in_keyword(self): data = ' CONTINUE' - expected = [(T.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.'), + expected = [(T.ERROR, 'CONTINUE', 3, 4, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'CONTINUE is not allowed in this context.')), (T.EOS, '', 3, 12)] self._verify(data, expected) def test_in_test(self): data = ' CONTINUE' - expected = [(T.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.'), + expected = [(T.ERROR, 'CONTINUE', 3, 4, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'CONTINUE is not allowed in this context.')), (T.EOS, '', 3, 12)] self._verify(data, expected, test=True) @@ -2155,13 +2160,13 @@ class TestBreak(unittest.TestCase): def test_in_keyword(self): data = ' BREAK' - expected = [(T.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.'), + expected = [(T.ERROR, 'BREAK', 3, 4, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'BREAK is not allowed in this context.')), (T.EOS, '', 3, 9)] self._verify(data, expected) def test_in_test(self): data = ' BREAK' - expected = [(T.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.'), + expected = [(T.ERROR, 'BREAK', 3, 4, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'BREAK is not allowed in this context.')), (T.EOS, '', 3, 9)] self._verify(data, expected, test=True) @@ -2520,8 +2525,8 @@ def test_invalid_per_file_config(self): ''' expected = [ (T.ERROR, 'language: in:va:lid', 1, 0, - "Invalid language configuration: Language 'in:va:lid' not found " - "nor importable as a language module."), + InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_LANGUAGE_CONFIGURATION, "Invalid language configuration: Language 'in:va:lid' not found " + "nor importable as a language module.")), (T.EOL, '\n', 1, 19), (T.EOS, '', 1, 20), (T.COMMENT, 'language: bad again', 2, 0), diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index b4f0db676f0..8a2912628fc 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1,25 +1,66 @@ import ast import os -import unittest import tempfile +import unittest from pathlib import Path -from robot.parsing import get_model, get_resource_model, ModelVisitor, ModelTransformer, Token +from parsing.parsing_test_utils import assert_model, remove_non_data + +from robot.parsing import ( + ModelTransformer, + ModelVisitor, + Token, + get_model, + get_resource_model, +) +from robot.parsing.lexer.tokens import ErrorCode, ErrorKind, InvalidTokenError from robot.parsing.model.blocks import ( - File, For, If, ImplicitCommentSection, InvalidSection, Try, While, - Keyword, KeywordSection, SettingSection, TestCase, TestCaseSection, VariableSection + File, + For, + If, + ImplicitCommentSection, + InvalidSection, + Keyword, + KeywordSection, + SettingSection, + TestCase, + TestCaseSection, + Try, + VariableSection, + While, ) from robot.parsing.model.statements import ( - Arguments, Break, Comment, Config, Continue, Documentation, ForHeader, End, ElseHeader, - ElseIfHeader, EmptyLine, Error, IfHeader, InlineIfHeader, TryHeader, ExceptHeader, - FinallyHeader, KeywordCall, KeywordName, Return, ReturnSetting, ReturnStatement, - SectionHeader, TestCaseName, TestTags, Var, Variable, WhileHeader + Arguments, + Break, + Comment, + Config, + Continue, + Documentation, + ElseHeader, + ElseIfHeader, + EmptyLine, + End, + Error, + ExceptHeader, + FinallyHeader, + ForHeader, + IfHeader, + InlineIfHeader, + KeywordCall, + KeywordName, + Return, + ReturnSetting, + ReturnStatement, + SectionHeader, + TestCaseName, + TestTags, + TryHeader, + Var, + Variable, + WhileHeader, ) from robot.utils.asserts import assert_equal, assert_raises_with_msg -from parsing_test_utils import assert_model, remove_non_data - - DATA = '''\ *** Test Cases *** @@ -1582,13 +1623,13 @@ def _verify_documentation(self, data, expected, value): class TestError(unittest.TestCase): def test_get_errors_from_tokens(self): - assert_equal(Error([Token('ERROR', error='xxx')]).errors, + assert_equal(Error([Token('ERROR', error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'xxx'))]).errors, ('xxx',)) - assert_equal(Error([Token('ERROR', error='xxx'), + assert_equal(Error([Token('ERROR', error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'xxx')), Token('ARGUMENT'), - Token('ERROR', error='yyy')]).errors, + Token('ERROR', error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'yyy'))]).errors, ('xxx', 'yyy')) - assert_equal(Error([Token('ERROR', error=e) for e in '0123456789']).errors, + assert_equal(Error([Token('ERROR', error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, e)) for e in '0123456789']).errors, tuple('0123456789')) def test_model_error(self): @@ -1606,7 +1647,7 @@ def test_model_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)] + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, inv_header))] ) ), @@ -1615,7 +1656,7 @@ def test_model_error(self): Token('SETTING HEADER', '*** Settings ***', 2, 0) ]), body=[ - Error([Token('ERROR', 'Invalid', 3, 0, inv_setting)]), + Error([Token('ERROR', 'Invalid', 3, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, inv_setting))]), Documentation([Token('DOCUMENTATION', 'Documentation', 4, 0)]) ] ) @@ -1630,7 +1671,7 @@ def test_model_error_with_fatal_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Test Cases ***', 1, 0, inv_testcases)]) + [Token('INVALID HEADER', '*** Test Cases ***', 1, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, inv_testcases))]) ) ]) assert_model(model, expected) @@ -1652,7 +1693,7 @@ def test_model_error_with_error_and_fatal_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Invalid ***', 1, 0, inv_header)] + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, inv_header))] ) ), SettingSection( @@ -1660,13 +1701,13 @@ def test_model_error_with_error_and_fatal_error(self): Token('SETTING HEADER', '*** Settings ***', 2, 0) ]), body=[ - Error([Token('ERROR', 'Invalid', 3, 0, inv_setting)]), + Error([Token('ERROR', 'Invalid', 3, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, inv_setting))]), Documentation([Token('DOCUMENTATION', 'Documentation', 4, 0)]), ] ), InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Test Cases ***', 5, 0, inv_testcases)] + [Token('INVALID HEADER', '*** Test Cases ***', 5, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, inv_testcases))] ) ), ]) @@ -1676,7 +1717,7 @@ def test_set_errors_explicitly(self): error = Error([]) error.errors = ('explicitly set', 'errors') assert_equal(error.errors, ('explicitly set', 'errors')) - error.tokens = [Token('ERROR', error='normal error'),] + error.tokens = (Token('ERROR', error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'normal error')),) assert_equal(error.errors, ('normal error', 'explicitly set', 'errors')) error.errors = ['errors', 'as', 'list'] @@ -1904,9 +1945,9 @@ def test_config(self): Token('EOL', '\n', 1, 12) ]), Error([ - Token('ERROR', 'language: bad', 2, 0, - "Invalid language configuration: Language 'bad' " - "not found nor importable as a language module."), + Token('ERROR', 'language: bad', 2, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_LANGUAGE_CONFIGURATION, + "Invalid language configuration: Language 'bad' " + "not found nor importable as a language module.")), Token('EOL', '\n', 2, 13) ]), Comment([ diff --git a/utest/parsing/test_statements.py b/utest/parsing/test_statements.py index dd718a4d6f4..0011099aace 100644 --- a/utest/parsing/test_statements.py +++ b/utest/parsing/test_statements.py @@ -1,9 +1,9 @@ import unittest +from robot.parsing import ErrorCode, ErrorKind, InvalidTokenError, Token from robot.parsing.model.statements import * -from robot.parsing import Token -from robot.utils.asserts import assert_equal, assert_true from robot.utils import type_name +from robot.utils.asserts import assert_equal, assert_true def assert_created_statement(tokens, base_class, **params): @@ -1057,6 +1057,19 @@ def test_EmptyLine(self): eol='\n' ) + def test_Error(self): + error_token = InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'Error message') + tokens = [ + Token(Token.SEPARATOR, ' '), + Token(Token.ERROR, value="", error=error_token), + Token(Token.EOL, '\n') + ] + assert_created_statement( + tokens, + Error, + error=error_token + ) + if __name__ == '__main__': unittest.main() diff --git a/utest/parsing/test_statements_in_invalid_position.py b/utest/parsing/test_statements_in_invalid_position.py index ec792614e43..1d84d32eb37 100644 --- a/utest/parsing/test_statements_in_invalid_position.py +++ b/utest/parsing/test_statements_in_invalid_position.py @@ -1,9 +1,10 @@ import unittest -from robot.parsing import get_model, Token -from robot.parsing.model.statements import Break, Continue, Error, ReturnStatement +from parsing.parsing_test_utils import RemoveNonDataTokensVisitor, assert_model -from parsing_test_utils import assert_model, RemoveNonDataTokensVisitor +from robot.parsing import Token, get_model +from robot.parsing.lexer.tokens import ErrorCode, ErrorKind, InvalidTokenError +from robot.parsing.model.statements import Break, Continue, Error, ReturnStatement def remove_non_data_nodes_and_assert(node, expected, data_only): @@ -23,7 +24,7 @@ def test_in_test_case_body(self): RETURN''', data_only=data_only) node = model.sections[0].body[0].body[0] expected = Error( - [Token(Token.ERROR, 'RETURN', 3, 4, 'RETURN is not allowed in this context.')], + [Token(Token.ERROR, 'RETURN', 3, 4, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'RETURN is not allowed in this context.'))], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -173,7 +174,7 @@ def test_in_test_case_body(self): BREAK''', data_only=data_only) node = model.sections[0].body[0].body[0] expected = Error( - [Token(Token.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.')], + [Token(Token.ERROR, 'BREAK', 3, 4, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'BREAK is not allowed in this context.'))], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -242,7 +243,7 @@ def test_in_uk_body(self): BREAK''', data_only=data_only) node = model.sections[0].body[0].body[0] expected = Error( - [Token(Token.ERROR, 'BREAK', 3, 4, 'BREAK is not allowed in this context.')], + [Token(Token.ERROR, 'BREAK', 3, 4,error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'BREAK is not allowed in this context.'))], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -292,7 +293,7 @@ def test_in_test_case_body(self): CONTINUE''', data_only=data_only) node = model.sections[0].body[0].body[0] expected = Error( - [Token(Token.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.')], + [Token(Token.ERROR, 'CONTINUE', 3, 4, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'CONTINUE is not allowed in this context.'))], ) remove_non_data_nodes_and_assert(node, expected, data_only) @@ -361,7 +362,7 @@ def test_in_uk_body(self): CONTINUE''', data_only=data_only) node = model.sections[0].body[0].body[0] expected = Error( - [Token(Token.ERROR, 'CONTINUE', 3, 4, 'CONTINUE is not allowed in this context.')], + [Token(Token.ERROR, 'CONTINUE', 3, 4, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'CONTINUE is not allowed in this context.'))], ) remove_non_data_nodes_and_assert(node, expected, data_only) diff --git a/utest/parsing/test_tokens.py b/utest/parsing/test_tokens.py index 828214749f2..a36941517c9 100644 --- a/utest/parsing/test_tokens.py +++ b/utest/parsing/test_tokens.py @@ -1,8 +1,8 @@ import unittest -from robot.utils.asserts import assert_equal, assert_false - from robot.api import Token +from robot.parsing.lexer.tokens import ErrorCode, ErrorKind, InvalidTokenError +from robot.utils.asserts import assert_equal, assert_false class TestToken(unittest.TestCase): @@ -33,6 +33,13 @@ def test_automatic_value(self): (Token.AS, 'AS')]: assert_equal(Token(typ).value, value) + def test_set_error(self): + token = Token(Token.RETURN, 'Hello', 1, 0) + assert_equal(token.error, None) + error_token = InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'Bad!') + token.set_error(error_token) + assert_equal(token.error, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'Bad!')) + assert_equal(token.type, "ERROR") class TestTokenizeVariables(unittest.TestCase): @@ -62,6 +69,21 @@ def test_tokenize_variables_is_generator(self): assert_false(isinstance(variables, list)) assert_equal(iter(variables), variables) +class TestTokenError(unittest.TestCase): + + def test_string_repr(self): + token_error = InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'Syntax error!') + expected_str = "InvalidTokenError(ERROR, SYNTAX_ERROR, 'Syntax error!')" + assert_equal(repr(token_error), expected_str) + + def test_to_string(self): + error = InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'Something went wrong!') + assert_equal(str(error), 'Something went wrong!') + + def test_is_warning(self): + error = InvalidTokenError(ErrorKind.WARNING, ErrorCode.SYNTAX_ERROR, 'Bad!') + assert_equal(error.is_warning, True) + if __name__ == '__main__': unittest.main() From d7a65aef1cc9ccc2b16eb0e719804e1d70abd572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Lepp=C3=A4nen?= Date: Sat, 19 Oct 2024 12:03:43 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Improve=20the=20ergono?= =?UTF-8?q?mics=20of=20the=20InvalidTokenError=20type=20by=20introducing?= =?UTF-8?q?=20as=5Fwarning=20and=20as=5Ferror=20custom=20constructors.=20A?= =?UTF-8?q?dd=20docs=20to=20InvalidTokenError=20and=20ErrorCode=20types.?= =?UTF-8?q?=20Add=20appropriate=20unit=20tests=20for=20new=20business=20lo?= =?UTF-8?q?gic.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/robot/parsing/lexer/context.py | 18 +++++-------- src/robot/parsing/lexer/settings.py | 6 ++--- src/robot/parsing/lexer/statementlexers.py | 12 ++++----- src/robot/parsing/lexer/tokens.py | 31 ++++++++++++++++++++++ utest/parsing/test_tokens.py | 8 ++++++ 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index 020dab6bb8b..dec2c50435e 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -89,8 +89,7 @@ def _handles_section(self, statement: StatementTokens, header: str) -> bool: if self.languages.headers.get(normalized) == header: return True if normalized == header[:-1]: - statement[0].error = InvalidTokenError( - kind=ErrorKind.WARNING, + statement[0].error = InvalidTokenError.as_warning( code=ErrorCode.SINGULAR_HEADER_DEPRECATED, message=f"Singular section headers like '{marker}' are deprecated. Use plural format like '*** {header} ***' instead.") return True @@ -113,8 +112,7 @@ def task_section(self, statement: StatementTokens) -> bool: return self._handles_section(statement, 'Tasks') def _get_invalid_section_error(self, header: str) -> InvalidTokenError: - return InvalidTokenError( - kind=ErrorKind.ERROR, + return InvalidTokenError.as_error( code=ErrorCode.INVALID_SECTION_HEADER, message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Test Cases', 'Tasks', 'Keywords' and 'Comments'.") @@ -125,12 +123,10 @@ class ResourceFileContext(FileContext): def _get_invalid_section_error(self, header: str) -> InvalidTokenError: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - return InvalidTokenError( - kind=ErrorKind.ERROR, + return InvalidTokenError.as_error( code=ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, message=f"Resource file with '{name}' section is invalid.", is_fatal=True) - return InvalidTokenError( - kind=ErrorKind.ERROR, + return InvalidTokenError.as_error( code=ErrorCode.INVALID_SECTION_HEADER, message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'.", is_fatal=True) @@ -142,12 +138,10 @@ class InitFileContext(FileContext): def _get_invalid_section_error(self, header: str) -> InvalidTokenError: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - return InvalidTokenError( - kind=ErrorKind.ERROR, + return InvalidTokenError.as_error( code=ErrorCode.INVALID_SECTION_IN_INIT_FILE, message=f"'{name}' section is not allowed in suite initialization file.") - return InvalidTokenError( - kind=ErrorKind.ERROR, + return InvalidTokenError.as_error( code=ErrorCode.INVALID_SECTION_HEADER, message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'.") diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index e0f6d08293b..ac8cc0e6070 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -68,7 +68,7 @@ def lex(self, statement: StatementTokens): try: self._validate(orig, name, statement) except ValueError as err: - self._lex_error(statement, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SETTINGS_VALIDATION_ERROR, err.args[0])) + self._lex_error(statement, InvalidTokenError.as_error(code=ErrorCode.SETTINGS_VALIDATION_ERROR, message=err.args[0])) else: self._lex_setting(statement, name) @@ -122,8 +122,8 @@ def _lex_setting(self, statement: StatementTokens, name: str): else: self._lex_arguments(values) if name == 'Return': - statement[0].error = InvalidTokenError(ErrorKind.WARNING, ErrorCode.RETURN_SETTING_DEPRECATED, - "The '[Return]' setting is deprecated. Use the 'RETURN' statement instead.") + statement[0].error = InvalidTokenError.as_warning(code=ErrorCode.RETURN_SETTING_DEPRECATED, + message="The '[Return]' setting is deprecated. Use the 'RETURN' statement instead.") def _lex_name_and_arguments(self, tokens: StatementTokens): if tokens: diff --git a/src/robot/parsing/lexer/statementlexers.py b/src/robot/parsing/lexer/statementlexers.py index b19ee5b1f94..b3f511c34c2 100644 --- a/src/robot/parsing/lexer/statementlexers.py +++ b/src/robot/parsing/lexer/statementlexers.py @@ -140,9 +140,9 @@ def input(self, statement: StatementTokens): try: self.ctx.add_language(lang) except DataError: - statement[0].set_error(InvalidTokenError(ErrorKind.ERROR, - ErrorCode.INVALID_LANGUAGE_CONFIGURATION, - f"Invalid language configuration: Language '{lang}' not found nor importable as a language module.")) + statement[0].set_error(InvalidTokenError.as_error( + code=ErrorCode.INVALID_LANGUAGE_CONFIGURATION, + message=f"Invalid language configuration: Language '{lang}' not found nor importable as a language module.")) else: statement[0].type = Token.CONFIG @@ -386,8 +386,8 @@ def handles(self, statement: StatementTokens) -> bool: def lex(self): token = self.statement[0] - token.set_error(InvalidTokenError(ErrorKind.ERROR, - ErrorCode.SYNTAX_ERROR, - f'{token.value} is not allowed in this context.')) + token.set_error(InvalidTokenError.as_error( + code=ErrorCode.SYNTAX_ERROR, + message=f'{token.value} is not allowed in this context.')) for t in self.statement[1:]: t.type = Token.ARGUMENT diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 2148b10803c..fa020cc0e90 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -27,6 +27,10 @@ class ErrorCode(Enum): + """ Error codes for invalid tokens. + + The error codes are used to identify the error that occurred when tokenizing data. + """ INVALID_LANGUAGE_CONFIGURATION = auto() INVALID_SECTION_HEADER = auto() INVALID_SECTION_IN_INIT_FILE = auto() @@ -45,6 +49,24 @@ class ErrorKind(Enum): @dataclass(frozen=True) class InvalidTokenError: + """ Error information for invalid tokens. + + :param kind: The kind of the error, either `ErrorKind.WARNING` or `ErrorKind.ERROR`. + :param code: The error code. + :param message: The error message. + :param is_fatal: Whether the error is fatal or not. + + The `kind` attribute is either `ErrorKind.WARNING` or `ErrorKind.ERROR` and the `code` attribute + is an instance of `ErrorCode`. The `message` attribute is a string describing the error. The + `is_fatal` attribute is a boolean indicating whether the error is fatal or not. If `is_fatal` is + `True`, the error should be treated as fatal and the parsing should be stopped. + + The `message` attribute is optional and defaults to `None`. The `is_fatal` attribute is optional + and defaults to `False`. The `is_warning` and `should_throw` properties can be used to check the + kind of the error. The `as_warning` and `as_error` class methods can be used to create new instances + of `InvalidTokenError` with the kind set to `ErrorKind.WARNING` or `ErrorKind.ERROR` respectively. + """ + kind: ErrorKind code: ErrorCode message: str | None = None @@ -69,6 +91,15 @@ def is_warning(self) -> bool: def should_throw(self) -> bool: return self.kind == ErrorKind.ERROR and self.is_fatal + @classmethod + def as_warning(cls, code: ErrorCode, message: str | None = None) -> 'InvalidTokenError': + return cls(ErrorKind.WARNING, code, message) + + @classmethod + def as_error(cls, code: ErrorCode, message: str | None = None, is_fatal: bool = False) -> 'InvalidTokenError': + return cls(ErrorKind.ERROR, code, message, is_fatal) + + class Token: """Token representing piece of Robot Framework data. diff --git a/utest/parsing/test_tokens.py b/utest/parsing/test_tokens.py index a36941517c9..e1dc2fd6e16 100644 --- a/utest/parsing/test_tokens.py +++ b/utest/parsing/test_tokens.py @@ -84,6 +84,14 @@ def test_is_warning(self): error = InvalidTokenError(ErrorKind.WARNING, ErrorCode.SYNTAX_ERROR, 'Bad!') assert_equal(error.is_warning, True) + def test_as_error(self): + error = InvalidTokenError.as_error(ErrorCode.SYNTAX_ERROR, 'Something went wrong!') + assert_equal(error, InvalidTokenError(ErrorKind.ERROR, ErrorCode.SYNTAX_ERROR, 'Something went wrong!')) + + def test_as_warning(self): + error = InvalidTokenError.as_warning(ErrorCode.RETURN_SETTING_DEPRECATED, 'Deprecated!') + assert_equal(error, InvalidTokenError(ErrorKind.WARNING, ErrorCode.RETURN_SETTING_DEPRECATED, 'Deprecated!')) + if __name__ == '__main__': unittest.main() From 72f742e26ef3e4088ce7026c062b1a3e398cc142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Lepp=C3=A4nen?= Date: Sun, 20 Oct 2024 13:46:08 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Introduced=20a=20new?= =?UTF-8?q?=20ErrorKind=20`FATAL`=20to=20simplify=20code.=20Earlier=20it?= =?UTF-8?q?=20required=20unnecessarily=20to=20check=20that=20InvalidTokenE?= =?UTF-8?q?rror=20is=20Error=20kind=20and=20has=20is=5Ffatal=20attribute?= =?UTF-8?q?=20set=20to=20true.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/robot/parsing/lexer/context.py | 9 +++--- src/robot/parsing/lexer/tokens.py | 34 ++++++++++++----------- src/robot/running/builder/transformers.py | 2 +- utest/parsing/test_lexer.py | 4 +-- utest/parsing/test_model.py | 6 ++-- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py index dec2c50435e..c6aac277b8f 100644 --- a/src/robot/parsing/lexer/context.py +++ b/src/robot/parsing/lexer/context.py @@ -123,13 +123,12 @@ class ResourceFileContext(FileContext): def _get_invalid_section_error(self, header: str) -> InvalidTokenError: name = self._normalize(header) if self.languages.headers.get(name) in ('Test Cases', 'Tasks'): - return InvalidTokenError.as_error( + return InvalidTokenError.as_fatal( code=ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, - message=f"Resource file with '{name}' section is invalid.", is_fatal=True) - return InvalidTokenError.as_error( + message=f"Resource file with '{name}' section is invalid.") + return InvalidTokenError.as_fatal( code=ErrorCode.INVALID_SECTION_HEADER, - message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'.", - is_fatal=True) + message=f"Unrecognized section header '{header}'. Valid sections: 'Settings', 'Variables', 'Keywords' and 'Comments'.") class InitFileContext(FileContext): diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index fa020cc0e90..4cd1828f76e 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -28,7 +28,7 @@ class ErrorCode(Enum): """ Error codes for invalid tokens. - + The error codes are used to identify the error that occurred when tokenizing data. """ INVALID_LANGUAGE_CONFIGURATION = auto() @@ -45,6 +45,7 @@ class ErrorCode(Enum): class ErrorKind(Enum): WARNING = 'WARNING' ERROR = 'ERROR' + FATAL = 'FATAL' @dataclass(frozen=True) @@ -54,23 +55,20 @@ class InvalidTokenError: :param kind: The kind of the error, either `ErrorKind.WARNING` or `ErrorKind.ERROR`. :param code: The error code. :param message: The error message. - :param is_fatal: Whether the error is fatal or not. - The `kind` attribute is either `ErrorKind.WARNING` or `ErrorKind.ERROR` and the `code` attribute - is an instance of `ErrorCode`. The `message` attribute is a string describing the error. The - `is_fatal` attribute is a boolean indicating whether the error is fatal or not. If `is_fatal` is - `True`, the error should be treated as fatal and the parsing should be stopped. + The `kind` attribute is either `ErrorCode.WARNING`, `ErrorCode.ERROR` or `ErrorCode.FATAL`. + The `message` attribute is a string describing the error. - The `message` attribute is optional and defaults to `None`. The `is_fatal` attribute is optional - and defaults to `False`. The `is_warning` and `should_throw` properties can be used to check the - kind of the error. The `as_warning` and `as_error` class methods can be used to create new instances - of `InvalidTokenError` with the kind set to `ErrorKind.WARNING` or `ErrorKind.ERROR` respectively. + The `message` attribute is optional and defaults to `None`. The `is_warning` and `is_fatal` properties + can be used to check the kind of the error. If `is_fatal` equals to `True` (ErrorKind.FATAL), the error + should be treated as fatal and the parsing should be stopped. The `as_warning`, `as_error` and `as_fatal` class methods + can be used to create new instances of `InvalidTokenError` with the kind set to `ErrorKind.WARNING`, + `ErrorKind.ERROR` or `ErrorKind.FATAL` respectively. """ - + kind: ErrorKind code: ErrorCode message: str | None = None - is_fatal: bool = False def __str__(self) -> str: return f"{self.message or ''}" @@ -88,16 +86,20 @@ def is_warning(self) -> bool: return self.kind == ErrorKind.WARNING @property - def should_throw(self) -> bool: - return self.kind == ErrorKind.ERROR and self.is_fatal + def is_fatal(self) -> bool: + return self.kind == ErrorKind.FATAL @classmethod def as_warning(cls, code: ErrorCode, message: str | None = None) -> 'InvalidTokenError': return cls(ErrorKind.WARNING, code, message) @classmethod - def as_error(cls, code: ErrorCode, message: str | None = None, is_fatal: bool = False) -> 'InvalidTokenError': - return cls(ErrorKind.ERROR, code, message, is_fatal) + def as_error(cls, code: ErrorCode, message: str | None = None) -> 'InvalidTokenError': + return cls(ErrorKind.ERROR, code, message) + + @classmethod + def as_fatal(cls, code: ErrorCode, message: str | None = None) -> 'InvalidTokenError': + return cls(ErrorKind.FATAL, code, message) class Token: diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 0125cf9b332..e70d048370c 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -521,7 +521,7 @@ def report_invalid_token_or_raise(self, token: Token): if token.error is None: # assert for the type checker that error is not None return message = f"Error in file '{self.source}' on line {token.lineno}: {token.error}" - if token.type == Token.INVALID_HEADER and token.error.should_throw: + if token.error.is_fatal: raise DataError(message) LOGGER.write(message, level='WARN' if token.error.is_warning else 'ERROR') diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 7062e0e97eb..cdb3da1576a 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -735,7 +735,7 @@ def test_case_section_causes_error_in_init_file(self): def test_case_section_causes_fatal_error_in_resource_file(self): assert_tokens('*** Test Cases ***', [ (T.INVALID_HEADER, '*** Test Cases ***', 1, 0, - InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, "Resource file with 'Test Cases' section is invalid.")), + InvalidTokenError(ErrorKind.FATAL, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, "Resource file with 'Test Cases' section is invalid.")), (T.EOS, '', 1, 18), ], get_resource_tokens, data_only=True) @@ -758,7 +758,7 @@ def test_invalid_section_in_init_file(self): def test_invalid_section_in_resource_file(self): assert_tokens('*', [ (T.INVALID_HEADER, '*', 1, 0, - InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, "Unrecognized section header '*'. Valid sections: " + InvalidTokenError(ErrorKind.FATAL, ErrorCode.INVALID_SECTION_HEADER, "Unrecognized section header '*'. Valid sections: " "'Settings', 'Variables', 'Keywords' and 'Comments'.")), (T.EOS, '', 1, 1), ], get_resource_tokens, data_only=True) diff --git a/utest/parsing/test_model.py b/utest/parsing/test_model.py index 8a2912628fc..78fa37f7c8c 100644 --- a/utest/parsing/test_model.py +++ b/utest/parsing/test_model.py @@ -1671,7 +1671,7 @@ def test_model_error_with_fatal_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Test Cases ***', 1, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, inv_testcases))]) + [Token('INVALID HEADER', '*** Test Cases ***', 1, 0, error=InvalidTokenError(ErrorKind.FATAL, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, inv_testcases))]) ) ]) assert_model(model, expected) @@ -1693,7 +1693,7 @@ def test_model_error_with_error_and_fatal_error(self): expected = File([ InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Invalid ***', 1, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_HEADER, inv_header))] + [Token('INVALID HEADER', '*** Invalid ***', 1, 0, error=InvalidTokenError(ErrorKind.FATAL, ErrorCode.INVALID_SECTION_HEADER, inv_header))] ) ), SettingSection( @@ -1707,7 +1707,7 @@ def test_model_error_with_error_and_fatal_error(self): ), InvalidSection( header=SectionHeader( - [Token('INVALID HEADER', '*** Test Cases ***', 5, 0, error=InvalidTokenError(ErrorKind.ERROR, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, inv_testcases))] + [Token('INVALID HEADER', '*** Test Cases ***', 5, 0, error=InvalidTokenError(ErrorKind.FATAL, ErrorCode.INVALID_SECTION_IN_RESOURCE_FILE, inv_testcases))] ) ), ])