| """Helpers for writing tests""" | |
| from __future__ import annotations | |
| import contextlib | |
| import os | |
| import os.path | |
| import re | |
| import shutil | |
| from collections.abc import Callable, Iterator | |
| from mypy import build | |
| from mypy.errors import CompileError | |
| from mypy.main import process_options | |
| from mypy.nodes import Expression, MypyFile | |
| from mypy.options import Options | |
| from mypy.test.config import test_temp_dir | |
| from mypy.test.data import DataDrivenTestCase, DataSuite | |
| from mypy.test.helpers import assert_string_arrays_equal | |
| from mypy.types import Type | |
| from mypyc.analysis.ircheck import assert_func_ir_valid | |
| from mypyc.common import IS_32_BIT_PLATFORM, PLATFORM_SIZE | |
| from mypyc.errors import Errors | |
| from mypyc.ir.func_ir import FuncIR | |
| from mypyc.ir.module_ir import ModuleIR | |
| from mypyc.irbuild.main import build_ir | |
| from mypyc.irbuild.mapper import Mapper | |
| from mypyc.options import CompilerOptions | |
| from mypyc.test.config import test_data_prefix | |
| # The builtins stub used during icode generation test cases. | |
| ICODE_GEN_BUILTINS = os.path.join(test_data_prefix, "fixtures/ir.py") | |
| # The testutil support library | |
| TESTUTIL_PATH = os.path.join(test_data_prefix, "fixtures/testutil.py") | |
| class MypycDataSuite(DataSuite): | |
| # Need to list no files, since this will be picked up as a suite of tests | |
| files: list[str] = [] | |
| data_prefix = test_data_prefix | |
| def builtins_wrapper( | |
| func: Callable[[DataDrivenTestCase], None], path: str | |
| ) -> Callable[[DataDrivenTestCase], None]: | |
| """Decorate a function that implements a data-driven test case to copy an | |
| alternative builtins module implementation in place before performing the | |
| test case. Clean up after executing the test case. | |
| """ | |
| return lambda testcase: perform_test(func, path, testcase) | |
| @contextlib.contextmanager | |
| def use_custom_builtins(builtins_path: str, testcase: DataDrivenTestCase) -> Iterator[None]: | |
| for path, _ in testcase.files: | |
| if os.path.basename(path) == "builtins.pyi": | |
| default_builtins = False | |
| break | |
| else: | |
| # Use default builtins. | |
| builtins = os.path.abspath(os.path.join(test_temp_dir, "builtins.pyi")) | |
| shutil.copyfile(builtins_path, builtins) | |
| default_builtins = True | |
| # Actually perform the test case. | |
| try: | |
| yield None | |
| finally: | |
| if default_builtins: | |
| # Clean up. | |
| os.remove(builtins) | |
| def perform_test( | |
| func: Callable[[DataDrivenTestCase], None], builtins_path: str, testcase: DataDrivenTestCase | |
| ) -> None: | |
| for path, _ in testcase.files: | |
| if os.path.basename(path) == "builtins.py": | |
| default_builtins = False | |
| break | |
| else: | |
| # Use default builtins. | |
| builtins = os.path.join(test_temp_dir, "builtins.py") | |
| shutil.copyfile(builtins_path, builtins) | |
| default_builtins = True | |
| # Actually perform the test case. | |
| func(testcase) | |
| if default_builtins: | |
| # Clean up. | |
| os.remove(builtins) | |
| def build_ir_for_single_file( | |
| input_lines: list[str], compiler_options: CompilerOptions | None = None | |
| ) -> list[FuncIR]: | |
| return build_ir_for_single_file2(input_lines, compiler_options)[0].functions | |
| def build_ir_for_single_file2( | |
| input_lines: list[str], compiler_options: CompilerOptions | None = None | |
| ) -> tuple[ModuleIR, MypyFile, dict[Expression, Type], Mapper]: | |
| program_text = "\n".join(input_lines) | |
| flags = re.search("# flags: (.*)$", program_text, flags=re.MULTILINE) | |
| # By default, generate IR compatible with the earliest supported Python C API. | |
| # If a test needs more recent API features, this should be overridden. | |
| compiler_options = compiler_options or CompilerOptions(capi_version=(3, 10)) | |
| if flags: | |
| flag_list = flags.group(1).split() | |
| _, options = process_options(flag_list, require_targets=False) | |
| else: | |
| options = Options() | |
| options.show_traceback = True | |
| options.hide_error_codes = True | |
| options.use_builtins_fixtures = True | |
| options.strict_optional = True | |
| options.python_version = compiler_options.python_version or (3, 10) | |
| options.export_types = True | |
| options.preserve_asts = True | |
| options.allow_empty_bodies = True | |
| options.strict_bytes = True | |
| options.disable_bytearray_promotion = True | |
| options.disable_memoryview_promotion = True | |
| options.per_module_options["__main__"] = {"mypyc": True} | |
| source = build.BuildSource("main", "__main__", program_text) | |
| # Construct input as a single source. | |
| # Parse and type check the input program. | |
| result = build.build(sources=[source], options=options, alt_lib_path=test_temp_dir) | |
| result.manager.metastore.close() | |
| if result.errors: | |
| raise CompileError(result.errors) | |
| errors = Errors(options) | |
| mapper = Mapper({"__main__": None}) | |
| modules = build_ir( | |
| [result.files["__main__"]], result.graph, result.types, mapper, compiler_options, errors | |
| ) | |
| if errors.num_errors: | |
| raise CompileError(errors.new_messages()) | |
| module = list(modules.values())[0] | |
| for fn in module.functions: | |
| assert_func_ir_valid(fn) | |
| tree = result.graph[module.fullname].tree | |
| assert tree is not None | |
| return module, tree, result.types, mapper | |
| def update_testcase_output(testcase: DataDrivenTestCase, output: list[str]) -> None: | |
| # TODO: backport this to mypy | |
| assert testcase.old_cwd is not None, "test was not properly set up" | |
| testcase_path = os.path.join(testcase.old_cwd, testcase.file) | |
| with open(testcase_path) as f: | |
| data_lines = f.read().splitlines() | |
| # We can't rely on the test line numbers to *find* the test, since | |
| # we might fix multiple tests in a run. So find it by the case | |
| # header. Give up if there are multiple tests with the same name. | |
| test_slug = f"[case {testcase.name}]" | |
| if data_lines.count(test_slug) != 1: | |
| return | |
| start_idx = data_lines.index(test_slug) | |
| stop_idx = start_idx + 11 | |
| while stop_idx < len(data_lines) and not data_lines[stop_idx].startswith("[case "): | |
| stop_idx += 1 | |
| test = data_lines[start_idx:stop_idx] | |
| out_start = test.index("[out]") | |
| test[out_start + 1 :] = output | |
| data_lines[start_idx:stop_idx] = test + [""] | |
| data = "\n".join(data_lines) | |
| with open(testcase_path, "w") as f: | |
| print(data, file=f) | |
| def assert_test_output( | |
| testcase: DataDrivenTestCase, | |
| actual: list[str], | |
| message: str, | |
| expected: list[str] | None = None, | |
| formatted: list[str] | None = None, | |
| ) -> None: | |
| __tracebackhide__ = True | |
| expected_output = expected if expected is not None else testcase.output | |
| if expected_output != actual and testcase.config.getoption("--update-data", False): | |
| update_testcase_output(testcase, actual) | |
| assert_string_arrays_equal( | |
| expected_output, actual, f"{message} ({testcase.file}, line {testcase.line})" | |
| ) | |
| def get_func_names(expected: list[str]) -> list[str]: | |
| res = [] | |
| for s in expected: | |
| m = re.match(r"def ([_a-zA-Z0-9.*$]+)\(", s) | |
| if m: | |
| res.append(m.group(1)) | |
| return res | |
| def remove_comment_lines(a: list[str]) -> list[str]: | |
| """Return a copy of array with comments removed. | |
| Lines starting with '--' (but not with '---') are removed. | |
| """ | |
| r = [] | |
| for s in a: | |
| if s.strip().startswith("--") and not s.strip().startswith("---"): | |
| pass | |
| else: | |
| r.append(s) | |
| return r | |
| def print_with_line_numbers(s: str) -> None: | |
| lines = s.splitlines() | |
| for i, line in enumerate(lines): | |
| print("%-4d %s" % (i + 1, line)) | |
| def heading(text: str) -> None: | |
| print("=" * 20 + " " + text + " " + "=" * 20) | |
| def show_c(cfiles: list[list[tuple[str, str]]]) -> None: | |
| heading("Generated C") | |
| for group in cfiles: | |
| for cfile, ctext in group: | |
| print(f"== {cfile} ==") | |
| print_with_line_numbers(ctext) | |
| heading("End C") | |
| def fudge_dir_mtimes(dir: str, delta: int) -> None: | |
| # Skip linker outputs. Pushing them back combines with write_file's | |
| # +1 sec bump on .c files to make .c always newer than .so, forcing | |
| # an unconditional rebuild that would mask Extension.depends bugs. | |
| # See setuptools/_distutils/command/build_ext.py:`build_extension`. | |
| for dirpath, _, filenames in os.walk(dir): | |
| for name in filenames: | |
| if name.endswith((".so", ".pyd", ".o", ".obj")): | |
| continue | |
| path = os.path.join(dirpath, name) | |
| new_mtime = os.stat(path).st_mtime + delta | |
| os.utime(path, times=(new_mtime, new_mtime)) | |
| def replace_word_size(text: list[str]) -> list[str]: | |
| """Replace WORDSIZE with platform specific word sizes""" | |
| result = [] | |
| for line in text: | |
| index = line.find("WORD_SIZE") | |
| if index != -1: | |
| # get 'WORDSIZE*n' token | |
| word_size_token = line[index:].split()[0] | |
| n = int(word_size_token[10:]) | |
| replace_str = str(PLATFORM_SIZE * n) | |
| result.append(line.replace(word_size_token, replace_str)) | |
| else: | |
| result.append(line) | |
| return result | |
| def infer_ir_build_options_from_test_name(name: str) -> CompilerOptions | None: | |
| """Look for magic substrings in test case name to set compiler options. | |
| Return None if the test case should be skipped (always pass). | |
| Supported naming conventions: | |
| *_64bit*: | |
| Run test case only on 64-bit platforms | |
| *_32bit*: | |
| Run test caseonly on 32-bit platforms | |
| *_python3_10* (or for any Python version): | |
| Use Python 3.10+ C API features (default: lowest supported version) | |
| *StripAssert*: | |
| Don't generate code for assert statements | |
| """ | |
| # If this is specific to some bit width, always pass if platform doesn't match. | |
| if "_64bit" in name and IS_32_BIT_PLATFORM: | |
| return None | |
| if "_32bit" in name and not IS_32_BIT_PLATFORM: | |
| return None | |
| options = CompilerOptions( | |
| strip_asserts="StripAssert" in name, capi_version=(3, 10), strict_traceback_checks=True | |
| ) | |
| # A suffix like _python3_10 is used to set the target C API version. | |
| m = re.search(r"_python([0-9]+)_([0-9]+)(_|\b)", name) | |
| if m: | |
| version = (int(m.group(1)), int(m.group(2))) | |
| assert version >= (3, 10), f"Unsupported _python* suffix: {name}" | |
| options.capi_version = version | |
| options.python_version = options.capi_version | |
| elif "_py" in name or "_Python" in name: | |
| assert False, f"Invalid _py* suffix (should be _pythonX_Y): {name}" | |
| if has_test_name_tag(name, "experimental"): | |
| options.experimental_features = True | |
| return options | |
| def has_test_name_tag(name: str, tag: str) -> bool: | |
| """Check if a test case name contains a tag token like ``_experimental``. | |
| A tag matches if it appears as a full underscore-delimited token: | |
| ``foo_tag_bar`` or ``foo_tag``. | |
| """ | |
| return re.search(rf"(?:^|_){re.escape(tag)}(?:_|$)", name) is not None |