From f0dc61a76403d3ad26cbfa8e3922fa84343ba7d4 Mon Sep 17 00:00:00 2001 From: Xavier Claessens Date: Tue, 14 Feb 2023 10:12:38 -0500 Subject: [PATCH] interpreter: Add testcase..endtestcase clause support This is currently only enabled when running unit tests to facilitate writing failing unit tests. Fixes: #11394 --- mesonbuild/interpreter/interpreter.py | 22 ++++++++++- mesonbuild/interpreterbase/__init__.py | 2 + mesonbuild/interpreterbase/baseobjects.py | 5 +++ mesonbuild/interpreterbase/interpreterbase.py | 13 +++++++ mesonbuild/mparser.py | 20 ++++++++++ .../common/261 testcase clause/meson.build | 37 +++++++++++++++++++ .../common/261 testcase clause/test.json | 9 +++++ unittests/datatests.py | 6 +++ 8 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 test cases/common/261 testcase clause/meson.build create mode 100644 test cases/common/261 testcase clause/test.json diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py index 2bf97dae7..9e20446b3 100644 --- a/mesonbuild/interpreter/interpreter.py +++ b/mesonbuild/interpreter/interpreter.py @@ -33,7 +33,7 @@ from ..interpreterbase import noPosargs, noKwargs, permittedKwargs, noArgsFlatte from ..interpreterbase import InterpreterException, InvalidArguments, InvalidCode, SubdirDoneRequest from ..interpreterbase import Disabler, disablerIfNotFound from ..interpreterbase import FeatureNew, FeatureDeprecated, FeatureNewKwargs, FeatureDeprecatedKwargs -from ..interpreterbase import ObjectHolder +from ..interpreterbase import ObjectHolder, ContextManagerObject from ..modules import ExtensionModule, ModuleObject, MutableModuleObject, NewExtensionModule, NotFoundExtensionModule from ..cmake import CMakeInterpreter from ..backend.backends import ExecutableSerialisation @@ -416,6 +416,8 @@ class Interpreter(InterpreterBase, HoldableObject): }) if 'MESON_UNIT_TEST' in os.environ: self.funcs.update({'exception': self.func_exception}) + if 'MESON_RUNNING_IN_PROJECT_TESTS' in os.environ: + self.funcs.update({'expect_error': self.func_expect_error}) def build_holder_map(self) -> None: ''' @@ -1395,6 +1397,24 @@ class Interpreter(InterpreterBase, HoldableObject): def func_exception(self, node, args, kwargs): raise RuntimeError('unit test traceback :)') + @noKwargs + @typed_pos_args('expect_error', str) + def func_expect_error(self, node: mparser.BaseNode, args: T.Tuple[str], kwargs: TYPE_kwargs) -> ContextManagerObject: + class ExpectErrorObject(ContextManagerObject): + def __init__(self, msg: str, subproject: str) -> None: + super().__init__(subproject) + self.msg = msg + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is None: + raise InterpreterException('Expecting an error but code block succeeded') + if isinstance(exc_val, mesonlib.MesonException): + msg = str(exc_val) + if msg != self.msg: + raise InterpreterException(f'Expecting error {self.msg!r} but got {msg!r}') + return True + return ExpectErrorObject(args[0], self.subproject) + def add_languages(self, args: T.List[str], required: bool, for_machine: MachineChoice) -> bool: success = self.add_languages_for(args, required, for_machine) if not self.coredata.is_cross_build(): diff --git a/mesonbuild/interpreterbase/__init__.py b/mesonbuild/interpreterbase/__init__.py index 13f55e5d3..53dca4946 100644 --- a/mesonbuild/interpreterbase/__init__.py +++ b/mesonbuild/interpreterbase/__init__.py @@ -18,6 +18,7 @@ __all__ = [ 'ObjectHolder', 'IterableObject', 'MutableInterpreterObject', + 'ContextManagerObject', 'MesonOperator', @@ -80,6 +81,7 @@ from .baseobjects import ( ObjectHolder, IterableObject, MutableInterpreterObject, + ContextManagerObject, TV_fw_var, TV_fw_args, diff --git a/mesonbuild/interpreterbase/baseobjects.py b/mesonbuild/interpreterbase/baseobjects.py index 820e0918c..d5b8c9476 100644 --- a/mesonbuild/interpreterbase/baseobjects.py +++ b/mesonbuild/interpreterbase/baseobjects.py @@ -22,6 +22,7 @@ import textwrap import typing as T from abc import ABCMeta +from contextlib import AbstractContextManager if T.TYPE_CHECKING: from typing_extensions import Protocol @@ -180,3 +181,7 @@ class IterableObject(metaclass=ABCMeta): def size(self) -> int: raise MesonBugException(f'size not implemented for {self.__class__.__name__}') + +class ContextManagerObject(MesonInterpreterObject, AbstractContextManager): + def __init__(self, subproject: 'SubProject') -> None: + super().__init__(subproject=subproject) diff --git a/mesonbuild/interpreterbase/interpreterbase.py b/mesonbuild/interpreterbase/interpreterbase.py index da64f688c..c8ef30309 100644 --- a/mesonbuild/interpreterbase/interpreterbase.py +++ b/mesonbuild/interpreterbase/interpreterbase.py @@ -26,6 +26,7 @@ from .baseobjects import ( InterpreterObjectTypeVar, ObjectHolder, IterableObject, + ContextManagerObject, HoldableTypes, ) @@ -231,6 +232,8 @@ class InterpreterBase: raise ContinueRequest() elif isinstance(cur, mparser.BreakNode): raise BreakRequest() + elif isinstance(cur, mparser.TestCaseClauseNode): + return self.evaluate_testcase(cur) else: raise InvalidCode("Unknown statement.") return None @@ -294,6 +297,16 @@ class InterpreterBase: self.evaluate_codeblock(node.elseblock) return None + def evaluate_testcase(self, node: mparser.TestCaseClauseNode) -> T.Optional[Disabler]: + result = self.evaluate_statement(node.condition) + if isinstance(result, Disabler): + return result + if not isinstance(result, ContextManagerObject): + raise InvalidCode(f'testcase clause {result!r} does not evaluate to a context manager.') + with result: + self.evaluate_codeblock(node.block) + return None + def evaluate_comparison(self, node: mparser.ComparisonNode) -> InterpreterObject: val1 = self.evaluate_statement(node.left) if val1 is None: diff --git a/mesonbuild/mparser.py b/mesonbuild/mparser.py index b0e817c8b..9840f9fdb 100644 --- a/mesonbuild/mparser.py +++ b/mesonbuild/mparser.py @@ -16,6 +16,7 @@ from __future__ import annotations from dataclasses import dataclass import re import codecs +import os import typing as T from .mesonlib import MesonException from . import mlog @@ -113,6 +114,9 @@ class Lexer: 'endif', 'and', 'or', 'not', 'foreach', 'endforeach', 'in', 'continue', 'break'} self.future_keywords = {'return'} + self.in_unit_test = 'MESON_RUNNING_IN_PROJECT_TESTS' in os.environ + if self.in_unit_test: + self.keywords.update({'testcase', 'endtestcase'}) self.token_specification = [ # Need to be sorted longest to shortest. ('ignore', re.compile(r'[ \t]')), @@ -466,6 +470,12 @@ class IfClauseNode(BaseNode): self.ifs = [] # type: T.List[IfNode] self.elseblock = None # type: T.Union[EmptyNode, CodeBlockNode] +class TestCaseClauseNode(BaseNode): + def __init__(self, condition: BaseNode, block: CodeBlockNode): + super().__init__(condition.lineno, condition.colno, condition.filename) + self.condition = condition + self.block = block + class UMinusNode(BaseNode): def __init__(self, current_location: Token, value: BaseNode): super().__init__(current_location.lineno, current_location.colno, current_location.filename) @@ -808,6 +818,12 @@ class Parser: return self.codeblock() return EmptyNode(self.current.lineno, self.current.colno, self.current.filename) + def testcaseblock(self) -> TestCaseClauseNode: + condition = self.statement() + self.expect('eol') + block = self.codeblock() + return TestCaseClauseNode(condition, block) + def line(self) -> BaseNode: block_start = self.current if self.current == 'eol': @@ -824,6 +840,10 @@ class Parser: return ContinueNode(self.current) if self.accept('break'): return BreakNode(self.current) + if self.lexer.in_unit_test and self.accept('testcase'): + block = self.testcaseblock() + self.block_expect('endtestcase', block_start) + return block return self.statement() def codeblock(self) -> CodeBlockNode: diff --git a/test cases/common/261 testcase clause/meson.build b/test cases/common/261 testcase clause/meson.build new file mode 100644 index 000000000..834865f2c --- /dev/null +++ b/test cases/common/261 testcase clause/meson.build @@ -0,0 +1,37 @@ +project('testcase clause') + +# To make sure unreachable code is not executed. +unreachable = true + +# Verify assertion exception gets catched and dropped. +testcase expect_error('Assert failed: false') + assert(false) + unreachable = false +endtestcase +assert(unreachable) + +# The inner testcase raises an exception because it did not receive the expected +# error message. The outer testcase catches the inner testcase exception and +# drop it. +testcase expect_error('Expecting error \'something\' but got \'Assert failed: false\'') + testcase expect_error('something') + assert(false) + unreachable = false + endtestcase + unreachable = false +endtestcase +assert(unreachable) + +# The inner testcase raises an exception because it did not receive an +# exception. The outer testcase catches the inner testcase exception and +# drop it. +testcase expect_error('Expecting an error but code block succeeded') + testcase expect_error('something') + reached = true + endtestcase + unreachable = false +endtestcase +assert(reached) +assert(unreachable) + +message('all good') diff --git a/test cases/common/261 testcase clause/test.json b/test cases/common/261 testcase clause/test.json new file mode 100644 index 000000000..650ae9f00 --- /dev/null +++ b/test cases/common/261 testcase clause/test.json @@ -0,0 +1,9 @@ +{ + "stdout": [ + { + "line": ".*all good", + "match": "re", + "count": 1 + } + ] +} diff --git a/unittests/datatests.py b/unittests/datatests.py index 9a46ec475..70fdcba46 100644 --- a/unittests/datatests.py +++ b/unittests/datatests.py @@ -219,11 +219,14 @@ class DataTests(unittest.TestCase): name = name.replace('_', '-') self.assertIn(name, html) + @unittest.mock.patch.dict(os.environ) def test_vim_syntax_highlighting(self): ''' Ensure that vim syntax highlighting files were updated for new functions in the global namespace in build files. ''' + # Disable unit test specific syntax + del os.environ['MESON_RUNNING_IN_PROJECT_TESTS'] env = get_fake_env() interp = Interpreter(FakeBuild(env), mock=True) with open('data/syntax-highlighting/vim/syntax/meson.vim', encoding='utf-8') as f: @@ -231,11 +234,14 @@ class DataTests(unittest.TestCase): defined = set([a.strip() for a in res.group().split('\\')][1:]) self.assertEqual(defined, set(chain(interp.funcs.keys(), interp.builtin.keys()))) + @unittest.mock.patch.dict(os.environ) def test_all_functions_defined_in_ast_interpreter(self): ''' Ensure that the all functions defined in the Interpreter are also defined in the AstInterpreter (and vice versa). ''' + # Disable unit test specific syntax + del os.environ['MESON_RUNNING_IN_PROJECT_TESTS'] env = get_fake_env() interp = Interpreter(FakeBuild(env), mock=True) astint = AstInterpreter('.', '', '')