Assembled from development commit 632297b7657a. Since 1b00738: when something
goes wrong while a klammer is being applied, the report begins with the
line of your document that applied it, then lists the klammers being
applied and where inside the klammer set the fault was raised; the
Python binding's line and column are the application's too. The default
image search path is the document's directory, imgsrc beside it and
../imgsrc, and a not-found report shows exactly those.
402 lines
14 KiB
Bash
Executable File
402 lines
14 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'
|
|
|
|
# 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 ]
|