Assembled from development commit d5f9c20b94b3. Since 5c29da5: a print() in
@eval, or in any Python a klammer set runs, is a log line -- on the
error stream under the command's name at -v 1, silent otherwise, never
in the document -- and K_log(level, text) is the builtin behind it; a
Python script using the binding keeps its own stdout. Previously such
output was lost, or on a terminal landed in the document.
413 lines
14 KiB
Bash
Executable File
413 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'
|
|
|
|
# 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 ]
|