Files
klammertext/tst/kpy_test.sh
T

412 lines
14 KiB
Bash
Raw Normal View History

#!/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'
# The binding does NOT redirect sys.stdout: a print() in a document's @eval
# is the host script's own stdout (under ktext it is a log line instead).
run <<'EOF'
import klammertext as kt
M = kt.Machine(klammersets="none")
M.read("@eval print('host stdout'); 1 @")
print("result", M.apply())
EOF
expect "12b. a print() in @eval reaches the host script's stdout" 'host stdout
result 1'
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 ]