diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9560291..5870207 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,12 +88,17 @@ jobs: path: ${{ matrix.target.headless-only && '.' || 'Kconfiglib' }} - name: Run pytest selftests - # Skip on Windows: several tests depend on Unix shell ($(shell,...)), - # gcc, and forward-slash paths that are unavailable on Windows CI. - if: ${{ matrix.target.headless-only != true }} working-directory: ${{ matrix.target.headless-only && '.' || 'Kconfiglib' }} run: | - python -m pytest tests/ -v --tb=short --ignore=tests/test_conformance.py + if [ "${{ matrix.target.headless-only }}" == "true" ]; then + # Windows: portable subset -- excludes tests that need gcc or + # Unix shell commands (Kpreprocess uses $(shell,...) with + # bash-specific syntax). + python -m pytest tests/test_preprocess.py -v --tb=short \ + -k "test_user_defined or test_success_failure or test_python_fn or test_kconfig_warn" + else + python -m pytest tests/ -v --tb=short --ignore=tests/test_conformance.py + fi - name: Apply Linux Kconfig Makefile patch # Skip for Windows (headless-only mode) diff --git a/KBUILD.md b/KBUILD.md index 4c057ef..29f16bd 100644 --- a/KBUILD.md +++ b/KBUILD.md @@ -1,6 +1,7 @@ # Kbuild Toolchain Functions -Kconfiglib implements Kbuild toolchain detection functions used by the Linux kernel since version 4.18. +Kconfiglib implements Kbuild toolchain detection functions used by the Linux kernel since version 4.18, +plus the portable `$(python,...)` built-in for cross-platform boolean checks. These preprocessor functions enable runtime detection of compiler, assembler, and linker capabilities, allowing kernel configurations to adapt to different toolchain versions. @@ -26,6 +27,39 @@ For comprehensive Kconfig syntax documentation, see the `$(failure,command)` : Returns `n` if command succeeds, `y` otherwise. Inverse of `success`. +### Portable In-Process Checks + +`$(python,code)` +: Evaluates a Python code string in-process via `exec()`. Returns `y` if +execution succeeds without exception, `n` otherwise. No subprocess, no shell, +no PATH dependency. Use `assert` for boolean checks. + +The exec namespace provides pre-imported modules and a shell-free subprocess helper: + +| Name | Type | Purpose | +|------------|----------|--------------------------------------------| +| `os` | module | env vars, paths, file ops | +| `sys` | module | platform, version, `sys.executable` path | +| `shutil` | module | `which()` for tool detection | +| `platform` | module | machine/system/architecture | +| `run` | function | shell-free subprocess, returns bool | + +`run(*argv)` executes a command as an argument list (`shell=False`) and returns +`True` if it exits with code 0, `False` otherwise. + +Each `exec()` call receives a fresh copy of the namespace so assignments in one +`$(python,...)` invocation do not leak into subsequent calls. + +`SystemExit` is handled specially: `SystemExit(0)` and `SystemExit(None)` map +to `y`; non-zero/non-empty codes map to `n`. `AssertionError` maps to `n` +silently (expected for boolean checks via `assert`). All other exceptions +(`NameError`, `SyntaxError`, etc.) map to `n` and emit a Kconfig warning +with the exception type and message, aiding diagnosis of typos in code strings. + +Trust model: `$(python,...)` has the same trust level as `$(shell,...)`. +Kconfig files are trusted code (like Makefiles). The restricted globals provide +scope isolation (no parser internals visible), not security sandboxing. + ### Compiler Detection `$(cc-option,flag[,fallback])` @@ -99,6 +133,48 @@ config HAS_32BIT def_bool "$(m32-flag)" != "" ``` +### Portable Checks with $(python,...) + +``` +# Boolean checks (in-process, no subprocess) +config PYTHON_AVAILABLE + def_bool $(python,) + +config HAS_CC + def_bool $(python,assert os.environ.get('CC')) + +config IS_LINUX + def_bool $(python,assert sys.platform == 'linux') + +config IS_X86_64 + def_bool $(python,assert platform.machine() == 'x86_64') + +config HAS_GCC + def_bool $(python,assert shutil.which('gcc')) + +# Shell-free subprocess checks +config CC_IS_CLANG + def_bool $(python,assert run(sys.executable, 'scripts/detect-compiler.py', '--is', 'Clang')) + +config HAVE_SDL2 + def_bool $(python,assert run('pkg-config', '--exists', 'sdl2')) +``` + +Commas inside `run(...)` are safe: the Kconfig preprocessor tracks parenthesis +depth and only splits on commas at the top level of the function call. + +Quoted strings are also safe: the preprocessor tracks single (`'`), double (`"`), +triple-single (`'''`), and triple-double (`"""`) quoted regions. Commas and +parentheses inside quotes are treated as literal characters, not argument +separators or nesting markers. Backslash escapes (`\"`, `\'`) inside quoted +regions are handled correctly. + +Use semicolons instead of commas for multi-statement code: +`$(python,import os; assert os.path.isfile('Makefile'))`. + +For string-valued results (e.g., getting the compiler type name), `$(shell,...)` +remains the right tool. `$(python,...)` only returns `y` or `n`. + ## Implementation ### Design @@ -110,6 +186,28 @@ Functions are implemented in `kconfiglib.py` following these principles: - Python 3.6+ using standard library only - Graceful error handling (missing tools return `n`) +### Shell-Free Toolchain Functions + +Toolchain functions (`cc-option`, `ld-option`, `as-instr`, `as-option`, +`cc-option-bit`, `rustc-option`) use `subprocess.Popen` with argument lists +(`shell=False`) and `os.devnull` instead of Unix shell syntax. This +eliminates shell injection from environment variables and Kconfig-supplied +options, and makes the functions portable to Windows. + +Internal helpers: + +`_run_argv(argv, stdin_data=None)` +: Runs a command as an argument list. Returns `True` if exit code is 0. +Used by all toolchain functions. + +`_run_cmd(command)` +: Runs a command via shell (`shell=True`). Used by `success`, `failure`, +and `if-success`, which accept user-supplied shell commands by design. + +`_run_helper(*argv)` +: Shell-free subprocess for `$(python,...)` code strings. Exposed as `run()` +in the exec namespace. + ### Environment Variables Functions respect standard build variables: @@ -119,33 +217,34 @@ Functions respect standard build variables: ### Performance -Functions execute shell commands during Kconfig parsing, which can be slow. -For applications that parse configurations repeatedly, consider implementing -caching or using `allow_empty_macros=True` to skip toolchain detection. +Toolchain functions spawn subprocesses during Kconfig parsing, which can be +slow. `$(python,...)` checks that don't call `run()` execute in-process with +no subprocess overhead. For applications that parse configurations repeatedly, +consider implementing caching or using `allow_empty_macros=True` to skip +toolchain detection. ## Testing -Four test suites validate the implementation: +Tests live in `tests/test_preprocess.py` (part of the pytest suite): -`test_issue111.py` -: Validates basic toolchain function parsing. +`test_kbuild_functions` +: Verifies toolchain functions (`cc-option`, `as-instr`, etc.) and +`$(python,...)` via Kconfig parsing. Exercises the full preprocessor path. -`test_issue109.py` -: Tests nested function calls and complex expressions. +`test_success_failure_fns` +: Tests `success`, `failure`, and `if-success` directly in Python using +`sys.executable` as a portable true/false replacement. -`test_kbuild_complete.py` -: Comprehensive suite with 35+ test cases covering all functions, edge cases, and error conditions. +`test_python_fn_isolation` +: Verifies that variable assignments in one `$(python,...)` call do not +leak into subsequent calls. -`test_kernel_compat.py` -: Real-world kernel Kconfig snippets from init/Kconfig, arch/x86/Kconfig, etc. +`test_python_fn_system_exit` +: Verifies `SystemExit` handling: `exit(0)` maps to `y`, non-zero to `n`. -Run all tests: +Run: ```bash -python3 test_basic_parsing.py && \ -python3 test_issue111.py && \ -python3 test_issue109.py && \ -python3 test_kbuild_complete.py && \ -python3 test_kernel_compat.py +python3 -m pytest tests/test_preprocess.py -v ``` ## Compatibility @@ -165,6 +264,30 @@ Tested with: - binutils 2.31+ - rustc 1.60+ (optional) +## Portability + +### Unix vs Windows + +| Unix shell idiom | Portable replacement | +|---|---| +| `$(shell,cmd 2>/dev/null && echo y \|\| echo n)` | `$(python,assert run('cmd', 'arg'))` | +| `$(shell,test -n "$CC" && echo y \|\| echo n)` | `$(python,assert os.environ.get('CC'))` | +| `$(shell,scripts/foo.py --flag ...)` | `$(python,assert run(sys.executable, 'scripts/foo.py', '--flag'))` | +| `$(shell,pkg-config --exists lib && echo y \|\| echo n)` | `$(python,assert run('pkg-config', '--exists', 'lib'))` | +| `$(success,true)` | `$(python,)` | +| `$(failure,false)` | `$(python,assert False)` | + +`$(shell,...)` remains necessary for string-valued output (e.g., compiler +type name, version strings). For boolean checks, prefer `$(python,...)` +on cross-platform projects. + +### Toolchain functions + +`cc-option`, `ld-option`, `as-instr`, `as-option`, `cc-option-bit`, and +`rustc-option` are portable by default. They use `subprocess.Popen` with +argument lists internally -- no shell involvement, no `/dev/null` path +dependency (`os.devnull` is used instead). + ## Real-World Examples From `arch/x86/Kconfig.cpu`: @@ -191,6 +314,18 @@ config SHADOW_CALL_STACK depends on $(cc-option,-fsanitize=shadow-call-stack -ffixed-x18) ``` +From a cross-platform project (Mado): +``` +config CC_IS_CLANG + def_bool $(python,assert run(sys.executable, 'scripts/detect-compiler.py', '--is', 'Clang')) + +config HAVE_SDL2 + def_bool $(python,assert run('pkg-config', '--exists', 'sdl2')) + +config CROSS_COMPILE_ENABLED + def_bool $(python,assert os.environ.get('CROSS_COMPILE')) +``` + ## See Also - [Kconfig Language](https://docs.kernel.org/kbuild/kconfig-language.html) - Complete syntax specification diff --git a/kconfiglib.py b/kconfiglib.py index 56b432e..0c7a053 100644 --- a/kconfiglib.py +++ b/kconfiglib.py @@ -546,7 +546,9 @@ def my_other_fn(kconf, name, arg_1, arg_2, ...): import errno import importlib import os +import platform import re +import shutil import sys # Get rid of some attribute lookups. These are obvious in context. @@ -1058,6 +1060,7 @@ def _init( "filename": (_filename_fn, 0, 0), "lineno": (_lineno_fn, 0, 0), "shell": (_shell_fn, 1, 1), + "python": (_python_fn, 0, 1), "warning-if": (_warning_if_fn, 2, 2), # Kbuild toolchain test functions "if-success": (_if_success_fn, 3, 3), @@ -2827,10 +2830,52 @@ def _expand_macro(self, s, i, args): new_args.append(s[arg_start : match.start()]) arg_start = i - else: # match.group() == "$(" + elif match.group() == "$(": # A nested macro call within the macro s, i = self._expand_macro(s, match.start(), args) + else: + # A quote character (" or '). Try to skip + # to the matching close delimiter, expanding + # macros along the way but ignoring commas + # and parentheses. Handles single ("/'") + # and triple ("""/''') quotes. + # + # If no matching close quote exists, the + # character is treated as literal so that + # unbalanced quotes in $(shell,...) etc. + # do not break parsing (backward compat). + quote = match.group() + saved_i = match.end() + i = saved_i + if s[i : i + 2] == quote * 2: + close = quote * 3 + i += 2 + else: + close = quote + clen = len(close) + while 1: + qi = s.find(close, i) + if qi < 0: + # No matching close -- treat the + # opening quote as literal text. + i = saved_i + break + # Find backslash escape and nested + # macro; process whichever comes first + # so a \-escape after a $() does not + # skip the macro expansion. + bi = s.find("\\", i) + mi = _macro_quote_search(s, i) + if 0 <= bi < qi and (not mi or bi < mi.start()): + i = bi + 2 + continue + if mi and mi.start() < qi: + s, i = self._expand_macro(s, mi.start(), args) + else: + i = qi + clen + break + def _fn_val(self, args): # Returns the result of calling the function args[0] with the arguments # args[1..len(args)-1]. Plain variables are treated as functions @@ -7105,40 +7150,108 @@ def _shell_fn(kconf, _, command): return "\n".join(stdout.splitlines()).rstrip("\n").replace("\n", " ") +def _run_helper(*argv): + # Shell-free command execution for use inside $(python,...) code strings. + # Returns True if command exits with code 0, False otherwise. + return _run_argv(list(argv)) + + +# Namespace exposed to $(python,...) code strings. +# Provides scope isolation (no parser internals visible) and +# pre-imported modules so code strings stay short. +_PYTHON_GLOBALS = { + "os": os, + "sys": sys, + "shutil": shutil, + "platform": platform, + "run": _run_helper, +} + + +def _python_fn(kconf, _, code=""): + # Evaluates a Python code string in-process. + # Returns "y" on success, "n" otherwise. + # + # A fresh copy of _PYTHON_GLOBALS is passed to each + # exec() call so that assignments in one $(python,...) + # invocation do not leak into subsequent calls. + # + # AssertionError is silent (the expected mechanism for + # boolean checks). Other exceptions emit a warning so + # that typos and bugs in code strings are visible. + try: + exec( # noqa: S102 -- intentional; $(python,...) is trusted like $(shell,...) + compile(code, "", "exec"), + _PYTHON_GLOBALS.copy(), + ) + return "y" + except SystemExit as e: + return "n" if e.code else "y" + except AssertionError: + return "n" + except Exception as e: + kconf._warn( + f"$(python,...) raised {type(e).__name__}: {e}", + kconf.loc, + ) + return "n" + + +# Timeout for toolchain subprocess calls (seconds). +# Prevents indefinite hangs from stuck compilers or +# linkers. Generous enough for cross-compilation on +# slow systems; tight enough to catch real hangs. +_SUBPROCESS_TIMEOUT = 30 + + def _run_cmd(command): - # Runs 'command' in a shell and returns True if it exits with 0. - # Returns False on any error (non-zero exit or exception). + # Runs 'command' in a shell and returns True if it + # exits with 0. Kills the process on timeout or + # any unexpected error. import subprocess try: proc = subprocess.Popen( - command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + command, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) - proc.communicate() + except Exception: + return False + + try: + proc.communicate(timeout=_SUBPROCESS_TIMEOUT) return proc.returncode == 0 except Exception: + proc.kill() + proc.wait() return False -def _run_cmd_in_tmpdir(cmd_fmt): - # Creates a temporary directory, formats 'cmd_fmt' with it, runs the - # command, and cleans up. Returns True if the command exits with 0. - import tempfile +def _run_argv(argv, stdin_data=None): + # Runs a command given as an argument list (no shell + # involvement). Returns True if exit code is 0. + # Kills the process on timeout or unexpected error. + import subprocess - tmpdir = None try: - tmpdir = tempfile.mkdtemp() - return _run_cmd(cmd_fmt.replace("{tmpdir}", tmpdir)) + proc = subprocess.Popen( + argv, + stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) except Exception: return False - finally: - if tmpdir: - try: - import shutil - shutil.rmtree(tmpdir) - except Exception: - pass + try: + proc.communicate(input=stdin_data, timeout=_SUBPROCESS_TIMEOUT) + return proc.returncode == 0 + except Exception: + proc.kill() + proc.wait() + return False def _success_fn(kconf, _, command): @@ -7146,46 +7259,61 @@ def _success_fn(kconf, _, command): def _cc_option_fn(kconf, _, option, fallback=""): + import tempfile + cc = os.environ.get("CC", "gcc") - return ( - "y" - if _run_cmd_in_tmpdir( - cc - + " -Werror " - + option - + " -c -x c /dev/null -o {tmpdir}/tmp.o 2>/dev/null" - ) - else "n" - ) + try: + with tempfile.TemporaryDirectory() as tmpdir: + tmpfile = os.path.join(tmpdir, "tmp.o") + argv = ( + cc.split() + + ["-Werror"] + + option.split() + + ["-c", "-x", "c", os.devnull, "-o", tmpfile] + ) + return "y" if _run_argv(argv) else "n" + except Exception: + return "n" def _ld_option_fn(kconf, _, option): ld = os.environ.get("LD", "ld") - return "y" if _run_cmd(f"{ld} -v {option} 2>/dev/null") else "n" + argv = ld.split() + ["-v"] + option.split() + return "y" if _run_argv(argv) else "n" def _as_instr_fn(kconf, _, instr, extra_flags=""): cc = os.environ.get("CC", "gcc") - cmd = ( - 'printf "%b\\n" "{}" | ' - "{} {} -Wa,--fatal-warnings -c -x assembler-with-cpp" - " -o /dev/null - 2>/dev/null" - ).format(instr.replace('"', '\\"'), cc, extra_flags) - return "y" if _run_cmd(cmd) else "n" + argv = cc.split() + if extra_flags: + argv += extra_flags.split() + argv += [ + "-Wa,--fatal-warnings", + "-c", + "-x", + "assembler-with-cpp", + "-o", + os.devnull, + "-", + ] + return "y" if _run_argv(argv, (instr + "\n").encode()) else "n" def _as_option_fn(kconf, _, option, fallback=""): + import tempfile + cc = os.environ.get("CC", "gcc") - return ( - "y" - if _run_cmd_in_tmpdir( - cc - + " " - + option - + " -c -x assembler /dev/null -o {tmpdir}/tmp.o 2>/dev/null" - ) - else "n" - ) + try: + with tempfile.TemporaryDirectory() as tmpdir: + tmpfile = os.path.join(tmpdir, "tmp.o") + argv = ( + cc.split() + + option.split() + + ["-c", "-x", "assembler", os.devnull, "-o", tmpfile] + ) + return "y" if _run_argv(argv) else "n" + except Exception: + return "n" def _if_success_fn(kconf, _, command, then_val, else_val): @@ -7198,39 +7326,39 @@ def _failure_fn(kconf, _, command): def _cc_option_bit_fn(kconf, _, option): cc = os.environ.get("CC", "gcc") - return ( - option - if _run_cmd(f"{cc} -Werror {option} -E -x c /dev/null -o /dev/null 2>/dev/null") - else "" + argv = ( + cc.split() + + ["-Werror"] + + option.split() + + ["-E", "-x", "c", os.devnull, "-o", os.devnull] ) + return option if _run_argv(argv) else "" def _rustc_option_fn(kconf, _, option): import tempfile rustc = os.environ.get("RUSTC", "rustc") - - tmpdir = None try: - tmpdir = tempfile.mkdtemp() - # Create a dummy Rust file - dummy_rs = os.path.join(tmpdir, "lib.rs") - with open(dummy_rs, "w") as f: - f.write("") - - cmd = f"{rustc} {option} --crate-type=rlib {dummy_rs} --out-dir={tmpdir} -o {tmpdir}/tmp.rlib 2>/dev/null" - - return "y" if _run_cmd(cmd) else "n" + with tempfile.TemporaryDirectory() as tmpdir: + dummy_rs = os.path.join(tmpdir, "lib.rs") + with open(dummy_rs, "w") as f: + f.write("") + + argv = ( + rustc.split() + + option.split() + + [ + "--crate-type=rlib", + dummy_rs, + "--out-dir=" + tmpdir, + "-o", + os.path.join(tmpdir, "tmp.rlib"), + ] + ) + return "y" if _run_argv(argv) else "n" except Exception: return "n" - finally: - if tmpdir: - try: - import shutil - - shutil.rmtree(tmpdir) - except Exception: - pass # @@ -7604,8 +7732,14 @@ def _re_search(regex): # variable assignment _assignment_rhs_match = _re_match(r"\s*(=|:=|\+=)\s*(.*)") -# Special characters/strings while expanding a macro ('(', ')', ',', and '$(') -_macro_special_search = _re_search(r"\(|\)|,|\$\(") +# Special characters/strings while expanding a macro ('(', ')', ',', '$(', and +# quotes). Quotes are tracked so that commas and parentheses inside quoted +# regions (e.g. $(python,assert "a,b" == "a,b")) are not treated as argument +# separators or nesting changes. +_macro_special_search = _re_search(r"\(|\)|,|\$\(|\"|'") + +# Nested macro start ('$(') inside a quoted region +_macro_quote_search = _re_search(r"\$\(") # Special characters/strings while expanding a string (quotes, '\', and '$(') _string_special_search = _re_search(r'"|\'|\\|\$\(') diff --git a/tests/Kbuild_functions b/tests/Kbuild_functions index e746c99..77c5a3f 100644 --- a/tests/Kbuild_functions +++ b/tests/Kbuild_functions @@ -1,19 +1,81 @@ -# Test Kbuild toolchain test functions +# Test Kbuild toolchain test functions and $(python,...) built-in -config TEST_SUCCESS - def_bool $(success,true) +# --- $(python,...) tests --- + +config TEST_PYTHON_SUCCESS + def_bool $(python,) + help + Empty code string = success = "y" + +config TEST_PYTHON_ASSERT_PASS + def_bool $(python,assert not "") + help + Assertion that passes = "y" + +config TEST_PYTHON_ASSERT_FAIL + def_bool $(python,assert False) + help + Assertion that fails = "n" + +config TEST_PYTHON_ENV + def_bool $(python,assert os.environ.get('CC')) + help + Check env var via os module + +config TEST_PYTHON_WHICH + def_bool $(python,assert shutil.which('python3') or shutil.which('python')) + help + Tool detection via shutil.which + +config TEST_PYTHON_RUN + def_bool $(python,assert run(sys.executable, '-c', '')) + help + Shell-free subprocess via run() + +config TEST_PYTHON_RUN_FAIL + def_bool $(python,assert not run(sys.executable, '-c', 'raise SystemExit(1)')) + help + Verify run() failure detection + +config TEST_PYTHON_QUOTE_COMMA + def_bool $(python,assert "a,b" == "a,b") + help + Commas inside double quotes must not split arguments + +config TEST_PYTHON_QUOTE_PAREN + def_bool $(python,assert "(" + ")" == "()") + help + Parentheses inside quotes must not affect nesting + +config TEST_PYTHON_SINGLE_QUOTE + def_bool $(python,assert 'x,y' == 'x,y') + help + Single-quoted commas must not split arguments + +config TEST_PYTHON_ESCAPED_QUOTE + def_bool $(python,assert "a\"b" == 'a"b') + help + Backslash-escaped quotes must not end quoted region + +config TEST_PYTHON_TRIPLE_QUOTE + def_bool $(python,assert """a,b"c""" == 'a,b"c') help - Test success function with command that returns 0 + Triple-quoted string with comma and embedded quote -config TEST_FAILURE - def_bool $(failure,false) +config TEST_PYTHON_TRIPLE_SINGLE + def_bool $(python,assert '''x,y'z''' == "x,y'z") help - Test failure function with command that returns 0 + Triple single-quoted string with comma and embedded quote + +GREETING = hello -config TEST_IF_SUCCESS - def_bool $(if-success,true,y,n) +config TEST_PYTHON_MACRO_BEFORE_ESCAPE + def_bool $(python,assert "$(GREETING)\n" == "hello\n") help - Test if-success function with true command + Macro expansion must happen before backslash-escape processing + inside quoted regions so $(GREETING) is expanded, not skipped. + +# --- Toolchain function tests (cc-option, ld-option, etc.) --- config CC_HAS_WALL def_bool $(cc-option,-Wall) @@ -56,30 +118,9 @@ config CC_STACK_USAGE_FLAG help Returns -fstack-usage if supported, empty otherwise -# Test nested function calls -config TEST_NESTED_SUCCESS_SHELL - def_bool $(success,test -n "$(shell,echo test)") - help - Test nested success and shell function calls - -config TEST_NESTED_IF_SUCCESS - def_bool $(if-success,test -z "$(shell,echo)",y,n) - help - Test deeply nested function calls with if-success - -# Test with environment variables -config TEST_CC_ENV - def_bool $(success,test -n "$CC") - help - Test if CC environment variable is set +# --- Failure cases --- -# Test failure cases config TEST_INVALID_OPTION def_bool $(cc-option,--this-option-does-not-exist-xyz) help Test cc-option with invalid flag (should return n) - -config TEST_FAILURE_TRUE - def_bool $(failure,true) - help - Test failure with true command (should return n) diff --git a/tests/test_preprocess.py b/tests/test_preprocess.py index 8a916c4..8dd9715 100644 --- a/tests/test_preprocess.py +++ b/tests/test_preprocess.py @@ -4,9 +4,19 @@ # Preprocessor tests: variable expansion, user-defined functions, Kbuild # toolchain test functions, and KCONFIG_WARN_UNDEF. +import os +import shutil +import sys + import pytest -from kconfiglib import Kconfig, KconfigError +from kconfiglib import ( + Kconfig, + KconfigError, + _success_fn, + _failure_fn, + _if_success_fn, +) from conftest import verify_value, verify_str @@ -263,22 +273,142 @@ def test_user_defined_functions(monkeypatch): # =========================================================================== -def test_kbuild_functions(): +def test_kbuild_functions(monkeypatch): + cc = ( + os.environ.get("CC") + or shutil.which("cc") + or shutil.which("gcc") + or shutil.which("clang") + ) + if not cc: + pytest.skip("no C compiler found") + monkeypatch.setenv("CC", cc) c = Kconfig("tests/Kbuild_functions") - verify_value(c, "TEST_SUCCESS", "y") - verify_value(c, "TEST_FAILURE", "y") - verify_value(c, "TEST_IF_SUCCESS", "y") - + # $(python,...) tests + verify_value(c, "TEST_PYTHON_SUCCESS", "y") + verify_value(c, "TEST_PYTHON_ASSERT_PASS", "y") + verify_value(c, "TEST_PYTHON_ASSERT_FAIL", "n") + verify_value(c, "TEST_PYTHON_ENV", "y") + verify_value(c, "TEST_PYTHON_WHICH", "y") + verify_value(c, "TEST_PYTHON_RUN", "y") + verify_value(c, "TEST_PYTHON_RUN_FAIL", "y") + + # Quote tracking -- commas/parens inside quotes must not + # split macro arguments + verify_value(c, "TEST_PYTHON_QUOTE_COMMA", "y") + verify_value(c, "TEST_PYTHON_QUOTE_PAREN", "y") + verify_value(c, "TEST_PYTHON_SINGLE_QUOTE", "y") + verify_value(c, "TEST_PYTHON_ESCAPED_QUOTE", "y") + verify_value(c, "TEST_PYTHON_TRIPLE_QUOTE", "y") + verify_value(c, "TEST_PYTHON_TRIPLE_SINGLE", "y") + verify_value(c, "TEST_PYTHON_MACRO_BEFORE_ESCAPE", "y") + + # Toolchain function tests (shell-free via _run_argv) verify_value(c, "CC_HAS_WALL", "y") verify_value(c, "CC_HAS_WERROR", "y") - + verify_value(c, "CC_HAS_FSTACK_PROTECTOR", "y") + verify_value(c, "AS_HAS_NOP", "y") verify_value(c, "TEST_INVALID_OPTION", "n") - verify_value(c, "TEST_FAILURE_TRUE", "n") - verify_value(c, "AS_HAS_NOP", "y") + # ld-option: result is platform-dependent (Apple ld rejects + # --version), just verify the symbol was evaluated + assert c.syms["LD_HAS_VERSION"].str_value in ("y", "n") + + # cc-option-bit returns the flag itself (string) or "" + val = c.syms["CC_STACK_USAGE_FLAG"].str_value + assert val in ( + "-fstack-usage", + "", + ), "CC_STACK_USAGE_FLAG should be the flag or empty" + + # AS_HAS_MOVQ (x86-64 only) and AS_HAS_CUSTOM_FLAG + # (-march=native, compiler-dependent) are intentionally not + # asserted here -- results vary by architecture and toolchain. + + +@pytest.fixture(scope="module") +def kbuild_kconfig(): + """Load Kbuild_functions once per module -- avoids repeated + subprocess toolchain probes across python_fn tests.""" + return Kconfig("tests/Kbuild_functions", warn_to_stderr=False) + + +def test_success_failure_fns(): + """Test success/failure/if-success functions directly with + sys.executable as a portable true/false replacement.""" + c = Kconfig("tests/Kbuild_functions") + # Double-quote the path -- works in both bash (Unix) and cmd.exe + # (Windows). shlex.quote uses single quotes, which cmd.exe + # does not understand. + py = f'"{sys.executable}"' + + assert _success_fn(c, "success", py + ' -c ""') == "y" + assert _success_fn(c, "success", py + ' -c "raise SystemExit(1)"') == "n" + + assert _failure_fn(c, "failure", py + ' -c ""') == "n" + assert _failure_fn(c, "failure", py + ' -c "raise SystemExit(1)"') == "y" + + assert _if_success_fn(c, "if-success", py + ' -c ""', "yes", "no") == "yes" + assert ( + _if_success_fn(c, "if-success", py + ' -c "raise SystemExit(1)"', "yes", "no") + == "no" + ) + + +def test_python_fn_isolation(kbuild_kconfig): + """Verify that assignments in one $(python,...) call do not leak + into subsequent calls.""" + python_fn = kbuild_kconfig._functions["python"][0] + c = kbuild_kconfig + + assert python_fn(c, "python", "leaked_var = 42") == "y" + assert python_fn(c, "python", "assert 'leaked_var' not in dir()") == "y" + + +def test_python_fn_system_exit(kbuild_kconfig): + """Verify SystemExit handling: exit(0) -> 'y', exit(1) -> 'n'.""" + python_fn = kbuild_kconfig._functions["python"][0] + c = kbuild_kconfig + + assert python_fn(c, "python", "raise SystemExit(0)") == "y" + assert python_fn(c, "python", "raise SystemExit(None)") == "y" + assert python_fn(c, "python", "raise SystemExit(1)") == "n" + assert python_fn(c, "python", "raise SystemExit('error')") == "n" + + # Falsy codes -> "y", truthy codes -> "n" + assert python_fn(c, "python", 'raise SystemExit("")') == "y" + assert python_fn(c, "python", "raise SystemExit([])") == "y" + assert python_fn(c, "python", "raise SystemExit([1])") == "n" + + +def test_python_fn_warnings(kbuild_kconfig): + """Verify that non-assertion exceptions generate warnings + while AssertionError is silent.""" + c = kbuild_kconfig + python_fn = c._functions["python"][0] + + # AssertionError: silent (expected for boolean checks) + c.warnings.clear() + assert python_fn(c, "python", "assert False") == "n" + assert len(c.warnings) == 0 + + # NameError: warns (typo in code string) + c.warnings.clear() + assert python_fn(c, "python", "nonexistent_var") == "n" + assert len(c.warnings) == 1 + assert "NameError" in c.warnings[0] + + # SyntaxError: warns (malformed code) + c.warnings.clear() + assert python_fn(c, "python", "def") == "n" + assert len(c.warnings) == 1 + assert "SyntaxError" in c.warnings[0] - verify_value(c, "TEST_NESTED_SUCCESS_SHELL", "y") + # Success: no warning + c.warnings.clear() + assert python_fn(c, "python", "x = 1") == "y" + assert len(c.warnings) == 0 # ===========================================================================