#!/bin/bash # # kpy_test.sh — the Python binding of the Klammermachine (kpy/). # # The binding is ktext in-process: a klammertext.Machine is set up through # the same set_command_state() and load_klammersets() the command uses, so # what this suite asserts is the CONTRACT the binding adds on top of the # engine, not the engine itself (the other suites do that): # # * the module's surface -- Machine, Error and the nine subclasses under # the C++ names, home; # * read()/read_file()/read_files()/apply()/write() behaving as the # command-line forms they mirror (-s, a file argument, -t, -o); # * the state crossing (set/get/names) and the ISOLATION of a script's # globals from a document's @eval code; # * the error conversion: the C++ kind decides the Python class, the # location becomes attributes, str(e) is the report ktext prints, and an # @eval that raised a Python exception has it as __cause__ with its # traceback -- the one place the conversion needed real work; # * the one-Machine rule and its release on garbage collection; # * the output policy: a script's stdout carries only what the script # printed, logging goes to stderr and only under verbose=. # # Engine tier: every case loads klammersets="none" and declares any target it # needs inline. The one exception asserts the DEFAULT -- that no # klammersets= loads the SKS -- by the -v 1 log line, as verbosity_test.sh # does, and applies no SKS klammer. # # Usage: ./kpy_test.sh (needs KLAMMERTEXT_HOME set and kpy/ built) # Exit code: 0 if all tests pass, 1 otherwise. PASS=0 FAIL=0 K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} PYTHON=${PYTHON:-python3} red=$'\033[31m' green=$'\033[32m' bold=$'\033[1m' reset=$'\033[0m' OUTF=$(mktemp "${TMPDIR:-/tmp}"/kpy_out.XXXXXX) ERRF=$(mktemp "${TMPDIR:-/tmp}"/kpy_err.XXXXXX) WORK=$(mktemp -d "${TMPDIR:-/tmp}"/kpy_work.XXXXXX) trap 'rm -rf "$OUTF" "$ERRF" "$WORK"' EXIT # Python splits PYTHONPATH on the system's list separator: ':' on Linux and # macOS, ';' on Windows (an MSYS2 shell runs a native python.org Python). if command -v cygpath >/dev/null 2>&1; then sep=';'; else sep=':'; fi export PYTHONPATH="$K/kpy${PYTHONPATH:+$sep$PYTHONPATH}" # A DEBUG=1 build instruments the module with AddressSanitizer, and an # instrumented extension imported into an uninstrumented python needs the # ASan runtime loaded FIRST -- the import otherwise fails with "ASan runtime # does not come first in initial library list". Preload it when the module # carries the symbol; the runtime's path comes from the compiler that built # the module (gcc's libasan.so, clang's libclang_rt.asan-*.so). libstdc++ # is preloaded WITH it: python does not link libstdc++, so at the runtime's # startup there is no __cxa_throw to intercept, and the first C++ exception # the engine throws dies with "CHECK failed: asan_interceptors.cpp ... # real___cxa_throw != 0" (every error case of this suite, 2026-09-08). # Linux only (LD_PRELOAD); macOS ASan runs are not supported here. MODULE=$(ls "$K"/kpy/klammertext.*.so "$K"/kpy/klammertext.*.pyd 2>/dev/null | head -1) if [ -z "$MODULE" ]; then echo "${red}FAIL${reset} kpy/ is not built (no kpy/klammertext.*.so or .pyd)" exit 1 fi if nm -D "$MODULE" 2>/dev/null | grep -q '__asan_init'; then for rt in "$(gcc -print-file-name=libasan.so 2>/dev/null)" \ "$(clang -print-file-name=libclang_rt.asan-x86_64.so 2>/dev/null)" \ "$(clang -print-file-name=libclang_rt.asan-aarch64.so 2>/dev/null)"; do if [ -n "$rt" ] && [ -f "$rt" ]; then stdcxx=$(g++ -print-file-name=libstdc++.so 2>/dev/null) export LD_PRELOAD="$rt${stdcxx:+:$stdcxx}${LD_PRELOAD:+:$LD_PRELOAD}" break fi done # A leak report at exit would turn every case's status nonzero; the # interpreter's own leaks are not the binding's. export ASAN_OPTIONS="detect_leaks=0${ASAN_OPTIONS:+:$ASAN_OPTIONS}" fi plain() { sed 's/\x1b\[[0-9;]*m//g'; } # run — runs the Python program on stdin; sets STATUS, fills OUTF/ERRF. STATUS=0 run() { (cd "$WORK" && "$PYTHON" - >"$OUTF" 2>"$ERRF") STATUS=$? } pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); } fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); } # expect LABEL REGEX — the program exited 0 and its stdout matches REGEX # (wrap-insensitively: newlines collapse to spaces). expect() { local label="$1" rgx="$2" if [ "$STATUS" -ne 0 ]; then fail "$label" "exit status $STATUS; stderr: $(plain < "$ERRF" | tr '\n' ' ' | head -c 300)" elif plain < "$OUTF" | tr '\n' ' ' | tr -s ' ' | grep -qE "$rgx"; then pass "$label" else fail "$label" "stdout: $(plain < "$OUTF" | tr '\n' ' ' | head -c 300)" fi } echo "${bold}Python binding tests${reset}" echo "====================" echo # --- 1. The module's surface ------------------------------------------------ run <<'EOF' import os, klammertext as kt names = ["Parsing_error", "File_error", "Target_error", "Klammerset_error", "Definition_error", "Argument_error", "Environment_error", "Recursion_error", "Internal_error"] ok = (issubclass(kt.Error, Exception) and all(issubclass(getattr(kt, n), kt.Error) for n in names) and kt.home == os.environ["KLAMMERTEXT_HOME"] and callable(kt.Machine)) print("surface", ok) EOF expect " 1. Machine, Error, the nine subclasses, home" 'surface True' # --- 2. Reading and applying ------------------------------------------------ run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.read("@@x : hello @@") M.read("@x@") print("result", repr(M.apply())) EOF expect " 2. read() is -s: a definition, then its application" "result 'hello'" run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.read("@eval 6 * 7 @") print("eval", M.apply()) EOF expect " 3. @eval runs in the host interpreter" 'eval 42' run <<'EOF' import klammertext as kt secret = "script-side" M = kt.Machine(klammersets="none") M.read("@eval 'secret' in globals() @") print("isolated", M.apply()) EOF expect " 4. a document's @eval does not see the script's globals" 'isolated False' run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.set("Name", "Andy") M.read("*Name*") print("subst", M.apply(), "|", M.get("K_target"), "|", "K_input_dir" in M.names()) EOF expect " 5. set()/get()/names(): data crosses through the state" 'subst Andy \| \* \| True' run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.read("@@@target one | fixture @@@ @@@target two | fixture @@@") M.read("@@w.one : ONE @@ @@w.two : TWO @@ @w@") first = M.apply("one") del M N = kt.Machine(klammersets="none") N.read("@@@target one | fixture @@@ @@@target two | fixture @@@") N.read("@@w.one : ONE @@ @@w.two : TWO @@ @w@") print("targets", first, N.apply("two")) EOF expect " 6. apply(target) overrides the constructor's target; one Machine per target" 'targets ONE TWO' # apply() reduces the katoms IN PLACE, as the engine does for ktext's single # apply: a second apply() on the same Machine cannot re-render under another # target (case 6 makes a second Machine for that), but it may follow further # reading, and then returns everything read so far. run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.read("@eval 'first' @") a = M.apply() M.read("@eval 'second' @") b = M.apply() print("accumulate", repr(a), repr(b.replace("\n", "|"))) EOF expect " 6a. apply() after more reading returns everything read so far" "accumulate 'first' 'first\\|\\|second'" # --- 3. Files and output ---------------------------------------------------- cat > "$WORK/doc.kt" <<'EOF' @@@target txt | fixture @@@ @@greet : hello from a file @@ @greet@ EOF run <<'EOF' import os, klammertext as kt M = kt.Machine(klammersets="none", target="txt", files=["doc.kt"]) M.read_files() out = M.output_filename print("files", M.apply(), "|", os.path.basename(out), "|", os.path.normpath(os.path.dirname(out)) == os.getcwd()) EOF expect " 7. files= and read_files(); output_filename beside the input" 'files hello from a file \| doc.txt \| True' run <<'EOF' import os, klammertext as kt M = kt.Machine(klammersets="none", target="txt", files=["doc.kt"]) M.read_file("doc.kt") M.apply() written = M.write() print("write", written == M.output_filename, repr(open(written).read())) EOF expect " 8. write() writes the result plus a newline and returns the name" "write True 'hello from a file\\\\n'" run <<'EOF' import os, klammertext as kt M = kt.Machine(klammersets="none", target="txt", files=["doc.kt"], output="out/renamed") M.read_file("doc.kt") M.apply() print("output", os.path.relpath(M.write()).replace(os.sep, "/"), os.path.exists("out/renamed.txt")) EOF expect " 9. output= is -o: directory plus basename" 'output out/renamed.txt True' run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.read("@@@target t | fixture @@@") M.read("#[ nothing survives ]#") print("empty", M.write(), M.apply("t") == "") EOF expect "10. write() of an empty result writes nothing and returns None" 'empty None True' run <<'EOF' import os, klammertext as kt os.makedirs("sub", exist_ok=True) open("sub/inc.kt", "w").write("included text\n") os.chdir("sub") M = kt.Machine(klammersets="none") M.read("@read inc.kt @") print("relative", M.apply()) EOF expect "11. a relative @read in read() text resolves against the cwd" 'relative included text' # --- 4. Errors -------------------------------------------------------------- run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: M.read("@nosuch x @") M.apply() print("no error") except kt.Error as e: print("error", type(e).__name__, e.kind, e.line, e.column, str(e).startswith("klammertext (definition error)"), "is not defined" in e.description) EOF expect "12. an undefined klammer: Definition_error with kind, line, column, report" 'error Definition_error definition 1 1 True True' # An error raised inside a klammer's body is located, for a script, at the # APPLICATION (line 3 here), not at the @eval in the definition (line 1). run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: M.read("@@bad x : @eval 1/*x* @ @@\ntext\n@bad 0 @") M.apply() print("no error") except kt.Error as e: print("error", e.line, e.column, "applying @bad" in str(e)) EOF expect "12a. an error inside a klammer body is located at the application" 'error 3 1 True' run <<'EOF' import klammertext as kt try: kt.Machine(klammersets="none", files=["missing.kt"]).read_files() except kt.File_error as e: print("file", e.kind, e.filename, "missing.kt" in e.description) EOF expect "13. a missing file: File_error, no location" 'file file None True' run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: M.read("@eval 1/0 @") except kt.Parsing_error as e: c = e.__cause__ print("cause", type(c).__name__, c.__traceback__ is not None, "ZeroDivisionError" in e.description) EOF expect "14. a Python exception in @eval is the Parsing_error's __cause__, with traceback" 'cause ZeroDivisionError True True' run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: M.read("@eval nosuchmodule.f() @") except kt.Argument_error as e: print("import", type(e.__cause__).__name__) EOF expect "15. a failed module import in @eval chains the ModuleNotFoundError" 'import ModuleNotFoundError' run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: M.read("@@f : x @f@ @@ @f@") M.apply() except kt.Recursion_error as e: print("recursion", e.kind) EOF expect "16. unbounded recursion: Recursion_error (the C++ name, not the builtin)" 'recursion recursion' run <<'EOF' import klammertext as kt try: kt.Machine(klammersets=["none", "sks"]) except kt.Klammerset_error as e: print("none", "cannot be combined" in e.description) EOF expect "17. \"none\" combined with a symbol: Klammerset_error, the command's rule" 'none True' run <<'EOF' import klammertext as kt try: kt.Machine(klammersets="none", files=[1, 2]) except TypeError as e: print("type", "files" in str(e)) EOF expect "18. a wrong argument type is a TypeError, not a Klammertext error" 'type True' # --- 5. One Machine at a time ----------------------------------------------- run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: kt.Machine(klammersets="none") print("second allowed") except RuntimeError as e: print("second", "Only one" in str(e)) del M N = kt.Machine(klammersets="none") N.read("@eval 'again' @") print("after del", N.apply()) EOF expect "19. a second Machine is a RuntimeError; a new one works after del" 'second True after del again' # --- 6. The output policy --------------------------------------------------- run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") M.read("@eval 1 + 1 @") print("quiet", M.apply()) EOF expect "20. verbose=0: a successful run says nothing on stderr" 'quiet 2' if [ "$STATUS" -eq 0 ] && [ ! -s "$ERRF" ]; then pass "20a. ... stderr is empty" else fail "20a. ... stderr is empty" "stderr: $(plain < "$ERRF" | head -c 200)" fi run <<'EOF' import klammertext as kt M = kt.Machine(verbose=1) print("default", M.get("K_verbose_level")) EOF expect "21. no klammersets=: the SKS is the default (stdout clean)" '^default 1 $' if plain < "$ERRF" | grep -q 'Klammerset (default): .*sks/sks\.k'; then pass "21a. ... and verbose=1 reports the file it loaded, on stderr" else fail "21a. ... and verbose=1 reports the file it loaded, on stderr" "stderr: $(plain < "$ERRF" | head -c 300)" fi run <<'EOF' import klammertext as kt M = kt.Machine(klammersets="none") try: M.read("@eval 1/0 @") except kt.Error: pass print("caught") EOF expect "22. a caught error prints nothing itself: no traceback, no report" '^caught $' if [ ! -s "$ERRF" ]; then pass "22a. ... stderr is empty" else fail "22a. ... stderr is empty" "stderr: $(plain < "$ERRF" | head -c 300)" fi echo echo "Passed: $PASS, Failed: $FAIL" [ "$FAIL" -eq 0 ]