Files
klammertext/tst/kpy_test.sh
T
ack fe475e774a Klammertext runs natively on Windows; a Windows source-install guide; the bundled sh for @eval :shell
Assembled from klammertext-dev f344a37d0f6e. The commands, the library,
the Standard Klammer Set, the reflection klammerset and the Python
binding build and run natively on Windows (MSYS2 UCRT64 gcc, python.org
Python), and both test tiers pass there. doc/install/windows_source_install.md
is the fifth install guide: a source install parallel to the Linux and
macOS ones, verified by following it on a pristine Windows 11.

Behaviour on every system: paths Klammertext prints use forward slashes;
output is LF; the command line is UTF-8 on Windows; @eval :shell is a
POSIX shell command everywhere -- on Windows it runs the sh Klammertext
ships, BusyBox for Windows (win/busybox/, GPLv2, with its licence and the
source pointer). KLAMMERTEXT_FONTS and KLAMMERTEXT_KLAMMERSETS are lists in
the system's PATH convention (';' on Windows). On Linux and macOS nothing
observable changes.
2026-09-25 00:32:55 +02:00

388 lines
13 KiB
Bash
Executable File

#!/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'
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 ]