| from __future__ import annotations | |
| import os | |
| import tempfile | |
| import unittest | |
| from mypyc.build import get_header_deps, resolve_cfile_deps | |
| from mypyc.ir.ops import BasicBlock | |
| from mypyc.ir.pprint import format_blocks, generate_names_for_ir | |
| from mypyc.irbuild.ll_builder import LowLevelIRBuilder | |
| from mypyc.options import CompilerOptions | |
| class TestMisc(unittest.TestCase): | |
| def test_debug_op(self) -> None: | |
| block = BasicBlock() | |
| builder = LowLevelIRBuilder( | |
| errors=None, options=CompilerOptions(strict_traceback_checks=True) | |
| ) | |
| builder.activate_block(block) | |
| builder.debug_print("foo") | |
| names = generate_names_for_ir([], [block]) | |
| code = format_blocks([block], names, {}) | |
| assert code[:-1] == ["L0:", " r0 = 'foo'", " CPyDebug_PrintObject(r0)"] | |
| class TestHeaderDeps(unittest.TestCase): | |
| """ | |
| Tests for the header-dependency tracking used to build `Extension.depends`, which drives | |
| setuptools' `newer_group` decision about whether to recompile a .o file on incremental builds. | |
| """ | |
| def test_get_header_deps_quoted_includes(self) -> None: | |
| # Quoted includes: the historical form. Used by the .c file to reach its own __native_<mod>.h / | |
| # __native_internal_<mod>.h. The `False` in each tuple marks the include as non-angled, which | |
| # `resolve_cfile_deps` uses to search the includer's directory. | |
| cfile = '#include "__native_caller.h"\n#include "__native_internal_caller.h"\n' | |
| assert get_header_deps([("caller.c", cfile)]) == [ | |
| (False, "__native_caller.h"), | |
| (False, "__native_internal_caller.h"), | |
| ] | |
| def test_get_header_deps_angle_bracket_includes(self) -> None: | |
| # Angle-bracket includes are also matched, and reported with is_angled=True so that the resolver | |
| # skips the includer's dir for them (matching the C preprocessor). The cross-group export header | |
| # is reached via `#include <other_group/__native_other.h>` in __native_internal_<mod>.h. Before | |
| # this was matched the dep was missed entirely and the consumer's .o was never invalidated when | |
| # the other group's struct layout shifted. | |
| cfile = "#include <Python.h>\n#include <lib/__native_functions.h>\n" | |
| assert get_header_deps([("caller.c", cfile)]) == [ | |
| (True, "Python.h"), | |
| (True, "lib/__native_functions.h"), | |
| ] | |
| def test_get_header_deps_mixed_and_whitespace(self) -> None: | |
| # The preprocessor tolerates whitespace and the leading-hash form. `get_header_deps` returns sorted | |
| # tuples — non-angled (False) sorts before angled (True), then alphabetical within each kind. | |
| cfile = '# include "a.h"\n# include <b.h>\n#include\t"c.h"\n' | |
| assert get_header_deps([("x.c", cfile)]) == [(False, "a.h"), (False, "c.h"), (True, "b.h")] | |
| def test_resolve_walks_transitively_through_headers(self) -> None: | |
| # Reproduces the bug scenario: caller's .c only directly includes caller's own headers, but | |
| # caller's __native_internal_caller.h includes the cross-group export header. The resolver | |
| # must follow that chain so setuptools sees the cross-group header as a dep. | |
| with tempfile.TemporaryDirectory() as tmp: | |
| build_dir = tmp | |
| os.makedirs(os.path.join(build_dir, "lib")) | |
| os.makedirs(os.path.join(build_dir, "other_group")) | |
| # caller.c's directly-included headers, both live alongside | |
| # caller.c under build/ (resolved via target_dir). | |
| internal_h = os.path.join(build_dir, "__native_internal_caller.h") | |
| caller_h = os.path.join(build_dir, "__native_caller.h") | |
| cross_group_h = os.path.join(build_dir, "lib", "__native_functions.h") | |
| unrelated_h = os.path.join(build_dir, "other_group", "__native_other.h") | |
| with open(caller_h, "w") as f: | |
| # Headers outside build/ (CPython's <Python.h>, lib-rt's <CPy.h>) don't resolve under | |
| # target_dir, so they get dropped during resolution and aren't recursed into. | |
| f.write("#include <Python.h>\n#include <CPy.h>\n") | |
| with open(internal_h, "w") as f: | |
| # This header includes a header in another group via angle brackets. Pre-fix, this dep | |
| # was invisible to setuptools. | |
| f.write( | |
| "#include <Python.h>\n" | |
| '#include "__native_caller.h"\n' | |
| "#include <lib/__native_functions.h>\n" | |
| ) | |
| with open(cross_group_h, "w") as f: | |
| f.write("struct export_table_lib___functions { int x; };\n") | |
| with open(unrelated_h, "w") as f: | |
| # Sibling group not reached from caller's chain => must NOT appear in the resolved set. | |
| f.write("struct unrelated { int x; };\n") | |
| # caller.c is in build_dir, so its includer-dir is build_dir. Both directly-included headers | |
| # are quoted (`False`); the cross-group header that __native_internal_caller.h reaches via | |
| # `<lib/__native_functions.h>` is found by the recursive walk re-reading the on-disk header. | |
| deps = resolve_cfile_deps( | |
| cfile_dir=build_dir, | |
| direct_includes=[ | |
| (False, "__native_caller.h"), | |
| (False, "__native_internal_caller.h"), | |
| ], | |
| target_dir=build_dir, | |
| ) | |
| assert deps == {caller_h, internal_h, cross_group_h}, ( | |
| f"expected the cross-group header to be reached transitively; " | |
| f"got {sorted(deps)!r}" | |
| ) | |
| def test_resolve_drops_unresolvable_includes(self) -> None: | |
| # `<Python.h>`, `<CPy.h>`, etc. don't live under target_dir, so they're dropped from depends. They | |
| # never change between builds, so this is the right behavior. Crucially, it stops setuptools' | |
| # `missing="newer"` from treating them as always-newer and force-rebuilding every translation unit. | |
| with tempfile.TemporaryDirectory() as tmp: | |
| cfile_dir = tmp | |
| deps = resolve_cfile_deps( | |
| cfile_dir=cfile_dir, | |
| direct_includes=[(True, "Python.h"), (True, "CPy.h"), (False, "init.c")], | |
| target_dir=cfile_dir, | |
| ) | |
| assert deps == set() | |
| def test_resolve_search_order_matches_preprocessor(self) -> None: | |
| # When the same header name exists both next to the includer and under target_dir, the C preprocessor | |
| # picks the includer-dir copy for `#include "shared.h"` and the target_dir copy for `#include <shared.h>`. | |
| # The resolver must record the same path the compiler will actually consume, otherwise mtimes of the | |
| # wrong file drive incremental rebuild decisions. | |
| with tempfile.TemporaryDirectory() as tmp: | |
| includer = os.path.join(tmp, "groupA") | |
| target = os.path.join(tmp, "build") | |
| os.makedirs(includer) | |
| os.makedirs(target) | |
| local_h = os.path.join(includer, "shared.h") | |
| global_h = os.path.join(target, "shared.h") | |
| with open(local_h, "w") as f: | |
| f.write("/* local */\n") | |
| with open(global_h, "w") as f: | |
| f.write("/* global */\n") | |
| # Quoted form: resolves to the includer-dir copy. | |
| assert resolve_cfile_deps( | |
| cfile_dir=includer, direct_includes=[(False, "shared.h")], target_dir=target | |
| ) == {local_h} | |
| # Angled form: skips the includer-dir copy, resolves under -I. | |
| assert resolve_cfile_deps( | |
| cfile_dir=includer, direct_includes=[(True, "shared.h")], target_dir=target | |
| ) == {global_h} |